diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..d20f8dd --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,74 @@ +name: Docs + +on: + # The Pages workflow builds the exact commit it publishes with this workflow. + workflow_call: + inputs: + ref: + description: Commit to check out and build + type: string + required: true + pages: + description: Prepare the documentation artifact for GitHub Pages + type: boolean + default: false + pull_request: + branches: + - master + paths: + - "docs/**" + - "cytetype/**" + - "pyproject.toml" + - "uv.lock" + - "tests/test_docs_render.py" + - "tests/test_docs_structure.py" + - "tests/test_docs_execution.py" + - ".github/workflows/docs.yml" + - ".github/workflows/pages.yml" + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-26.04 + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref || github.sha }} + # The build reads the version and the page history from git. + fetch-depth: 0 + - name: Install uv + uses: astral-sh/setup-uv@v9.0.0 + with: + python-version: "3.13" + - uses: actions/setup-node@v7 + with: + node-version: "24" + - name: Install documentation dependencies + run: uv sync --locked --group docs + - name: Documentation tests + run: uv run --no-sync pytest tests/test_docs_structure.py tests/test_docs_render.py tests/test_docs_execution.py + - name: Render complete documentation for GitHub Pages + if: inputs.pages + env: + BASE_URL: /CyteType + run: make -C docs html + - name: Render complete documentation and check API coverage + if: ${{ !inputs.pages }} + # Notebook execution submits real CyteType jobs and runs locally on request. + # This job runs no notebooks. + run: make -C docs html + - name: Store documentation preview + if: ${{ !inputs.pages }} + uses: actions/upload-artifact@v7 + with: + name: documentation-preview + path: docs/build/site/_build/html/ + if-no-files-found: error + - name: Store GitHub Pages site + if: inputs.pages + # Served at docs.nygen.io/CyteType/ by the organization site, which owns the root robots.txt. + uses: actions/upload-pages-artifact@v4 + with: + path: docs/build/site/_build/html/ diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..3c8874a --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,46 @@ +name: GitHub Pages + +on: + workflow_dispatch: + push: + branches: + - master + paths: + - "docs/**" + - "cytetype/**" + - "pyproject.toml" + - "uv.lock" + - "tests/test_docs_render.py" + - "tests/test_docs_structure.py" + - "tests/test_docs_execution.py" + - ".github/workflows/docs.yml" + - ".github/workflows/pages.yml" + +permissions: + contents: read + +concurrency: + group: github-pages + cancel-in-progress: false + +jobs: + build: + if: github.ref == 'refs/heads/master' + uses: ./.github/workflows/docs.yml + with: + ref: ${{ github.sha }} + pages: true + + deploy: + needs: build + runs-on: ubuntu-26.04 + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: https://docs.nygen.io/CyteType/ + steps: + - name: Deploy GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index b48556e..34ced77 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -3,8 +3,17 @@ name: CI on: push: branches: [ master ] # Or your default branch + # The Docs workflow checks documentation-only changes. + paths-ignore: + - "docs/**" + - ".github/workflows/docs.yml" + - ".github/workflows/pages.yml" pull_request: branches: [ master ] # Or your default branch + paths-ignore: + - "docs/**" + - ".github/workflows/docs.yml" + - ".github/workflows/pages.yml" jobs: test: diff --git a/.gitignore b/.gitignore index eb274c0..2776dd0 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,9 @@ cytetype/_version.py *ipynb_checkpoints* *query.json notebooks/ -tmp/ \ No newline at end of file +tmp/ + +# Documentation. build/ above covers docs/build/; running MyST directly in docs/source +# writes _build/ there. The notebooks in docs/source are tracked page sources. +docs/source/_build/ +!/docs/source/**/*.ipynb diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..04a71bf --- /dev/null +++ b/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2025-2026, Nygen Analytics AB +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/LICENSE.md b/LICENSE.md deleted file mode 100644 index 7392954..0000000 --- a/LICENSE.md +++ /dev/null @@ -1,31 +0,0 @@ -# License - -## CyteType Client (this repository) - -MIT License - use, modify, and distribute freely. - -``` -Copyright (c) 2025 Nygen Analytics AB - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the β€œSoftware”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED β€œAS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` - -## CyteType API - -### Academic Use (Free) -- Rate-limited API access for academic research and education -- **No rate limits** when using your own LLM -- Generated data under [CC BY-SA-NC 4.0 (non-commercial)](https://creativecommons.org/licenses/by-nc-sa/4.0/) - -### Commercial Use & Higher Rate Limits -- Requires a commercial use license from Nygen Analytics AB -- Generated data includes commercial rights -- Contact: contact@nygen.io - -### Getting API Access -1. Academic users: Start with free rate-limited access -2. Need higher rates or commercial use: contact@nygen.io diff --git a/README.md b/README.md index 7781e72..74960c6 100644 --- a/README.md +++ b/README.md @@ -10,18 +10,20 @@ PyPI version - - License: CC BY-NC-SA 4.0 + + License: BSD 3-Clause PyPI downloads

-**CyteType** is an end-to-end cell type annotation system for **single-cell RNA sequencing (scRNA-seq)**, designed for repeatable analysis pipelines rather than one-off prompting. It combines cluster-level marker genes, expression context, study metadata, literature retrieval, ontology mapping, and a dedicated review step in a structured workflow that operates directly on AnnData. +**CyteType** is an end-to-end cell type annotation system for **single-cell RNA sequencing (scRNA-seq)**, designed for repeatable analysis pipelines rather than one-off prompting. It combines cluster-level marker genes, expression context, study metadata, ontology mapping, and a dedicated review step in a structured workflow that operates directly on AnnData. For Seurat workflows, use [CyteTypeR](https://github.com/NygenAnalytics/CyteTypeR). +πŸ“š **Documentation:** [docs.nygen.io/CyteType](https://docs.nygen.io/CyteType/) covers the Python and R clients, the report, the dashboard, Chat and the MCP connector. + > [!IMPORTANT] > Already installed CyteType? Open a terminal and run `pip install -U cytetype` to update to the latest version before you start. > @@ -55,8 +57,9 @@ cytetype login import scanpy as sc from cytetype import CyteType -# Assumes preprocessed AnnData with clusters and marker genes +# Assumes log-normalized AnnData with clusters in adata.obs["clusters"] group_key = "clusters" +sc.tl.rank_genes_groups(adata, groupby=group_key, key_added=f"rank_genes_{group_key}") annotator = CyteType( adata, group_key=group_key, @@ -67,18 +70,18 @@ adata = annotator.run(study_context="Human PBMC from a healthy donor") sc.pl.umap(adata, color="cytetype_annotation_clusters") ``` -[Try CyteType in Google Colab](https://colab.research.google.com/drive/1aRLsI3mx8JR8u5BKHs48YUbLsqRsh2N7?usp=sharing). +Follow the [full quickstart](https://docs.nygen.io/CyteType/getting-started/quickstart/), or [try CyteType in Google Colab](https://colab.research.google.com/drive/1aRLsI3mx8JR8u5BKHs48YUbLsqRsh2N7?usp=sharing). ## What You Get - **Annotations:** Cell type, subtype, and activation state for every cluster - **Cell Ontology mapping:** Standardized CL IDs for comparison across studies -- **Confidence and quality control:** Confidence values, plus match scores against your existing labels -- **Supporting evidence:** Publications and condition-specific references behind each call +- **Confidence and quality control:** Reviewer confidence (High, Moderate, Low) and heterogeneity flags, plus match scores against your existing labels +- **Supporting evidence:** Supporting, missing and unexpected marker genes, pathways, and the reasoning behind each call ## Example Report -Each analysis generates an HTML report with annotation decisions, reviewer comments, supporting evidence, and an embedded chat interface connected to your expression data. +Each analysis generates an interactive report with annotation decisions, reviewer assessments and supporting evidence. From the report, open [Chat](https://docs.nygen.io/CyteType/platform/chat/) to ask follow-up questions about your data. CyteType HTML report showing cell type annotations marker genes @@ -98,10 +101,14 @@ Methods and full results are in the [preprint](https://www.biorxiv.org/content/1 ## Resources -- πŸ” [CLI and Authentication](docs/cli.md): Set up API keys and manage saved credentials. -- βš™οΈ [Configuration](docs/configuration.md): Customize annotation settings, LLM providers, and artifacts. -- πŸ“‹ [Output Columns](docs/results.md): Understand annotations and metadata added to AnnData. -- πŸ› οΈ [Troubleshooting](docs/troubleshooting.md): Resolve authentication, API, artifact, and LLM issues. +- πŸ“š [Documentation](https://docs.nygen.io/CyteType/): Guides for the Python and R clients, the report, the dashboard, Chat and the MCP connector. +- πŸ” [Sign in and API keys](https://docs.nygen.io/CyteType/getting-started/sign-in/): Set up API keys and manage saved credentials. +- 🧬 [Prepare your data](https://docs.nygen.io/CyteType/guides/prepare-data/): Clusters, marker genes, gene symbols and other inputs. +- βš™οΈ [Run, monitor and rerun jobs](https://docs.nygen.io/CyteType/guides/running-jobs/): Annotation settings, timeouts, reruns and upload files. +- πŸ€– [Use your own LLM](https://docs.nygen.io/CyteType/guides/llm-configuration/): LLM providers, including local models. +- πŸ“‹ [Results in AnnData](https://docs.nygen.io/CyteType/results/results-in-your-data/): Columns and keys added to AnnData. +- πŸ› οΈ [Troubleshooting](https://docs.nygen.io/CyteType/support/troubleshooting/): Resolve sign-in, data, upload and job issues. +- 🐍 [Python API reference](https://docs.nygen.io/CyteType/api/index.html): Every public class and function. - πŸ§‘β€πŸ’» [Development](docs/development.md): Configure a local environment and contribute. - πŸŽ₯ [Introduction video](https://vimeo.com/nygen/cytetype): Watch a quick overview of CyteType. - πŸ’¬ [Discord community](https://discord.gg/V6QFM4AN): Ask questions and get support. @@ -123,6 +130,13 @@ Methods and full results are in the [preprint](https://www.biorxiv.org/content/1 ## License -CyteType is free for academic and non-commercial research under [CC BY-NC-SA 4.0](LICENSE.md). +The CyteType Python client is open source software released under the [BSD 3-Clause License](LICENSE) and maintained by [Nygen](https://nygen.io). + +### CyteType service terms + +Annotation runs on the CyteType service, which requires an API key. + +- **Academic and non-commercial use** is free within the [usage limits](https://docs.nygen.io/CyteType/platform/limits/). Data generated under academic use is licensed under [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/). +- **Commercial use and higher limits** require a commercial license from Nygen Analytics AB, which includes commercial rights to the generated data. -For commercial licensing, contact [contact@nygen.io](mailto:contact@nygen.io). +For licensing, contact [contact@nygen.io](mailto:contact@nygen.io). diff --git a/cytetype/__init__.py b/cytetype/__init__.py index 6718fe9..1113fcd 100644 --- a/cytetype/__init__.py +++ b/cytetype/__init__.py @@ -1,6 +1,22 @@ import requests from ._version import __version__ +from .api.exceptions import ( + APIError, + AuthenticationError, + AuthorizationError, + ClientDisconnectedError, + CyteTypeError, + JobFailedError, + JobNotFoundError, + LLMValidationError, + NetworkError, + QuotaExceededError, + RateLimitError, +) + +# Renamed so `from cytetype import *` does not shadow the builtin TimeoutError. +from .api.exceptions import TimeoutError as CyteTypeTimeoutError from .config import logger from .main import CyteType from .plotting import marker_dotplot @@ -12,6 +28,19 @@ "marker_dotplot", "rank_genes_groups_backed", "subsample_by_group", + # Exceptions + "CyteTypeError", + "APIError", + "AuthenticationError", + "AuthorizationError", + "RateLimitError", + "QuotaExceededError", + "JobNotFoundError", + "JobFailedError", + "LLMValidationError", + "ClientDisconnectedError", + "NetworkError", + "CyteTypeTimeoutError", ] _PYPI_JSON_URL = "https://pypi.org/pypi/cytetype/json" diff --git a/cytetype/api/__init__.py b/cytetype/api/__init__.py index d8c5331..c5fdddd 100644 --- a/cytetype/api/__init__.py +++ b/cytetype/api/__init__.py @@ -24,10 +24,15 @@ JobNotFoundError, JobFailedError, LLMValidationError, + ClientDisconnectedError, TimeoutError, NetworkError, ) +# The same class under the name that `cytetype` exports, which does not shadow the +# builtin TimeoutError. +from .exceptions import TimeoutError as CyteTypeTimeoutError + __all__ = [ # Client functions "submit_annotation_job", @@ -51,6 +56,10 @@ "JobNotFoundError", "JobFailedError", "LLMValidationError", + "ClientDisconnectedError", + # `from cytetype.api import *` has always exported TimeoutError, shadowing the + # builtin, and code that catches it expects CyteType's class, so it stays. "TimeoutError", + "CyteTypeTimeoutError", "NetworkError", ] diff --git a/cytetype/api/client.py b/cytetype/api/client.py index 0befbac..82879bd 100644 --- a/cytetype/api/client.py +++ b/cytetype/api/client.py @@ -8,13 +8,14 @@ from .transport import HTTPTransport from .progress import ProgressDisplay from .exceptions import JobFailedError, TimeoutError, APIError, NetworkError +from .normalization import normalize_cluster_annotation from .schemas import UploadResponse, UploadFileKind from ..config import logger MAX_UPLOAD_BYTES: dict[UploadFileKind, int] = { "obs_duckdb": 2 * 1024 * 1024 * 1024, # 2GB - "vars_h5": 50 * 1024 * 1024 * 1024, # 10GB + "vars_h5": 50 * 1024 * 1024 * 1024, # 50GB } _CHUNK_RETRY_DELAYS = (1, 5, 20) @@ -304,7 +305,14 @@ def fetch_job_results( auth_token: str | None, job_id: str, ) -> dict[str, Any]: - """Fetch and transform results for completed job.""" + """Fetch the results of a completed job and normalize them. + + Each cluster with an annotation becomes one entry of ``annotations``, + normalized by ``normalize_cluster_annotation``: blank legacy fields are + filled from the structured output and ``reviewerConfidence``, + ``isHeterogeneous`` and ``heterogeneityType`` are added. The API's + per-cluster data is returned unchanged as ``raw_annotations``. + """ transport = HTTPTransport(base_url, auth_token) status_code, results_data = transport.get(f"results/{job_id}") @@ -328,40 +336,9 @@ def fetch_job_results( # Convert dictionary format to list format for backward compatibility annotations_list = [] for cluster_id, cluster_data in annotations_dict.items(): - if isinstance(cluster_data, dict) and "latest" in cluster_data: - latest_data = cluster_data["latest"] - if isinstance(latest_data, dict) and "annotation" in latest_data: - annotation_data = latest_data["annotation"] - if isinstance(annotation_data, dict): - # Transform to expected format - transformed_annotation = { - "clusterId": annotation_data.get("clusterId", cluster_id), - "annotation": annotation_data.get("annotation", "Unknown"), - "ontologyTerm": annotation_data.get( - "cellOntologyTermName", "Unknown" - ), - "ontologyTermID": annotation_data.get( - "cellOntologyTerm", "Unknown" - ), - "granularAnnotation": annotation_data.get( - "granularAnnotation", "" - ), - "cellState": annotation_data.get("cellState", ""), - "justification": annotation_data.get("justification", ""), - "supportingMarkers": annotation_data.get( - "supportingMarkers", [] - ), - "conflictingMarkers": annotation_data.get( - "conflictingMarkers", [] - ), - "missingExpression": annotation_data.get( - "missingExpression", "" - ), - "unexpectedExpression": annotation_data.get( - "unexpectedExpression", "" - ), - } - annotations_list.append(transformed_annotation) + normalized = normalize_cluster_annotation(cluster_id, cluster_data) + if normalized is not None: + annotations_list.append(normalized) return { "annotations": annotations_list, @@ -455,7 +432,7 @@ def wait_for_completion( # Warn about consecutive 404s with auth token if auth_token and consecutive_not_found >= 3: logger.warning( - "⚠️ Getting consecutive 404 responses. " + "Getting consecutive 404 responses. " "This might indicate authentication issues." ) consecutive_not_found = 0 # Reset to avoid spam diff --git a/cytetype/api/exceptions.py b/cytetype/api/exceptions.py index fe4c529..db6420c 100644 --- a/cytetype/api/exceptions.py +++ b/cytetype/api/exceptions.py @@ -15,7 +15,7 @@ class APIError(CyteTypeError): # API exceptions coupled to specific error codes class AuthenticationError(APIError): - """Authentication failed - INVALID_TOKEN, TOKEN_INACTIVE.""" + """Authentication failed - INVALID_TOKEN, TOKEN_INACTIVE, AUTHENTICATION_REQUIRED.""" pass @@ -85,6 +85,7 @@ def __init__( ERROR_CODE_TO_EXCEPTION = { "INVALID_TOKEN": AuthenticationError, "TOKEN_INACTIVE": AuthenticationError, + "AUTHENTICATION_REQUIRED": AuthenticationError, "ACCESS_DENIED": AuthorizationError, "LICENSE_EXPIRED": AuthorizationError, "VPN_BLOCKED": AuthorizationError, diff --git a/cytetype/api/normalization.py b/cytetype/api/normalization.py new file mode 100644 index 0000000..b1850c1 --- /dev/null +++ b/cytetype/api/normalization.py @@ -0,0 +1,201 @@ +"""Normalization of per-cluster annotation results. + +- Jobs since 2026-01-25 store the legacy fields (``justification``, + ``supportingMarkers``, ...) empty and keep the content in + ``latest.annotation.fullOutput.cellType``. Blank legacy fields are filled + from there; non-blank values are never overwritten. The server's read-time + fill in ``GET /results`` applies the same legacy-field rules, so both must + change together. +- ``reviewerConfidence``, ``isHeterogeneous`` and ``heterogeneityType`` are + added from ``latest.review`` (SDK-only keys). + +Stored results may be partial or malformed, so every access is type-checked. +""" + +from collections.abc import Mapping +from typing import Any + +REVIEWER_CONFIDENCE_LEVELS: tuple[str, ...] = ("High", "Moderate", "Low") + + +def _as_dict(value: object) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _is_blank(value: object) -> bool: + if value is None: + return True + if isinstance(value, str): + return not value.strip() + return isinstance(value, list) and not value + + +def _gene_list(value: object) -> list[str] | None: + """Return the non-blank string items of ``value``, or None if it is not a list.""" + if not isinstance(value, list): + return None + return [item for item in value if isinstance(item, str) and item.strip()] + + +def _gene_text(reasoning: object, genes: object) -> str | None: + if isinstance(reasoning, str) and reasoning.strip(): + return reasoning + gene_list = _gene_list(genes) + return None if gene_list is None else ", ".join(gene_list) + + +def _legacy_field_sources( + cell_type: Mapping[str, Any], +) -> dict[str, str | list[str] | None]: + """Map each legacy field to its value from ``fullOutput.cellType`` (None if unavailable).""" + conclusion = cell_type.get("conclusion") + return { + "justification": conclusion if isinstance(conclusion, str) else None, + "supportingMarkers": _gene_list(cell_type.get("keySupportingGenes")), + "conflictingMarkers": _gene_list(cell_type.get("unexpectedGenes")), + "missingExpression": _gene_text( + cell_type.get("missingGenesReasoning"), cell_type.get("missingGenes") + ), + "unexpectedExpression": _gene_text( + cell_type.get("unexpectedGenesReasoning"), cell_type.get("unexpectedGenes") + ), + } + + +def _review_fields(latest: Mapping[str, Any]) -> dict[str, Any]: + review = _as_dict(latest.get("review")) + confidence = review.get("confidence") + is_heterogeneous = review.get("isHeterogeneous") + heterogeneity_type = _as_dict( + _as_dict(review.get("fullOutput")).get("heterogeneityAssessment") + ).get("heterogeneityType") + return { + "reviewerConfidence": ( + confidence + if isinstance(confidence, str) and confidence in REVIEWER_CONFIDENCE_LEVELS + else None + ), + "isHeterogeneous": ( + is_heterogeneous if isinstance(is_heterogeneous, bool) else None + ), + "heterogeneityType": ( + heterogeneity_type + if isinstance(heterogeneity_type, str) and heterogeneity_type.strip() + else None + ), + } + + +def fill_cluster_annotation( + entry: Mapping[str, Any], + cluster_data: object, +) -> dict[str, Any]: + """Return a normalized copy of one ``annotations`` entry (fill-only). + + Blank legacy fields are filled from ``latest.annotation.fullOutput.cellType`` + of the cluster's raw result; non-blank values are kept. The SDK-only keys are + added when missing or None. Neither argument is modified. + + Args: + entry: The cluster's entry in the ``annotations`` list. + cluster_data: The cluster's raw result (``{"latest": ..., "history": ...}``). + + Returns: + A new dict with the normalized entry. + """ + normalized = dict(entry) + latest = _as_dict(_as_dict(cluster_data).get("latest")) + annotation = _as_dict(latest.get("annotation")) + cell_type = _as_dict(annotation.get("fullOutput")).get("cellType") + # A structured cellType marks gen-2 and V2 results; legacy results have none. + if isinstance(cell_type, dict): + for field, value in _legacy_field_sources(cell_type).items(): + if value is not None and _is_blank(normalized.get(field)): + normalized[field] = value + for key, value in _review_fields(latest).items(): + if normalized.get(key) is None: + normalized[key] = value + return normalized + + +def normalize_cluster_annotation( + cluster_id: str, + cluster_data: object, +) -> dict[str, Any] | None: + """Build the normalized ``annotations`` entry for one raw cluster result. + + Args: + cluster_id: The cluster's key in the API response's ``annotations``. + cluster_data: The cluster's raw result (``{"latest": ..., "history": ...}``). + + Returns: + The normalized entry, or None if the cluster has no annotation. + """ + annotation = _as_dict(_as_dict(cluster_data).get("latest")).get("annotation") + if not isinstance(annotation, dict): + return None + entry = { + "clusterId": annotation.get("clusterId", cluster_id), + "annotation": annotation.get("annotation", "Unknown"), + "ontologyTerm": annotation.get("cellOntologyTermName", "Unknown"), + "ontologyTermID": annotation.get("cellOntologyTerm", "Unknown"), + "granularAnnotation": annotation.get("granularAnnotation", ""), + "cellState": annotation.get("cellState", ""), + "justification": annotation.get("justification", ""), + "supportingMarkers": annotation.get("supportingMarkers", []), + "conflictingMarkers": annotation.get("conflictingMarkers", []), + "missingExpression": annotation.get("missingExpression", ""), + "unexpectedExpression": annotation.get("unexpectedExpression", ""), + } + return fill_cluster_annotation(entry, cluster_data) + + +def renormalize_results(result: Mapping[str, Any]) -> dict[str, Any]: + """Re-apply the fill-only normalization to stored results. + + Results saved by older versions gain the filled legacy fields and the + SDK-only keys from their ``raw_annotations``. Without ``raw_annotations`` + (files from SDK 0.7.0 and older) the SDK-only keys are added as None, so + every entry has the same keys. + + Args: + result: Stored results, possibly the object held in ``adata.uns``. + + Returns: + A new dict; ``result`` and its nested objects are not modified. + """ + normalized = dict(result) + annotations = result.get("annotations") + if not isinstance(annotations, list): + return normalized + raw_annotations = result.get("raw_annotations") + if not isinstance(raw_annotations, dict): + raw_annotations = {} + + # fetch_job_results made one entry per raw cluster with an annotation dict, + # in raw order, so match entries by consuming those clusters in order. This + # keeps entries with a duplicated clusterId paired with their own cluster. + pending: dict[str, list[Any]] = {} + for cluster_id, cluster_data in raw_annotations.items(): + annotation = _as_dict(_as_dict(cluster_data).get("latest")).get("annotation") + if not isinstance(annotation, dict): + continue + entry_id = annotation.get("clusterId", cluster_id) + if isinstance(entry_id, str): + pending.setdefault(entry_id, []).append(cluster_data) + + renormalized: list[Any] = [] + for entry in annotations: + if not isinstance(entry, dict): + renormalized.append(entry) + continue + cluster_id = entry.get("clusterId") + cluster_data = None + if isinstance(cluster_id, str): + matches = pending.get(cluster_id) + cluster_data = ( + matches.pop(0) if matches else raw_annotations.get(cluster_id) + ) + renormalized.append(fill_cluster_annotation(entry, cluster_data)) + normalized["annotations"] = renormalized + return normalized diff --git a/cytetype/api/schemas.py b/cytetype/api/schemas.py index 45ec539..8470111 100644 --- a/cytetype/api/schemas.py +++ b/cytetype/api/schemas.py @@ -13,13 +13,16 @@ def _get_client_version() -> str | None: LLMProvider: TypeAlias = Literal[ "anthropic", "bedrock", + "deepseek", "fireworks", "google", "groq", "huggingface", "mistral", + "nebius", "openai", "openrouter", + "together", "vertex", "xai", ] @@ -27,6 +30,7 @@ def _get_client_version() -> str | None: "contextualizer", "annotator", "reviewer", "summarizer", "clinician", "chat" ] UploadFileKind: TypeAlias = Literal["obs_duckdb", "vars_h5"] +JobVisibility: TypeAlias = Literal["private", "org", "public"] class LLMModelConfig(BaseModel): @@ -52,7 +56,7 @@ class LLMModelConfig(BaseModel): ) skipValidation: bool = Field( default=False, - description="Whether to skip validation for the model. Only turn this off if you are sure that the model has worked before.", + description="Whether to skip validation for the model. Only turn this on if you are sure that the model has worked before. The server currently validates user-provided models regardless of this flag.", ) allowFallback: bool = Field( default=False, @@ -90,7 +94,7 @@ class InputData(BaseModel): ) infoTags: dict[str, str] = Field( default_factory=dict, - description="Optional metadata key-value pairs to display in the report header. Values that look like URLs will be made clickable.", + description="Optional metadata key-value pairs to display in the report header. Values must be strings; values that look like URLs will be made clickable. A 'title' key also names the job.", ) clusterLabels: dict[str, str] = Field( default_factory=dict, @@ -116,7 +120,7 @@ class InputData(BaseModel): default=2, ge=1, le=50, - description="Number of parallel requests to make to the model", + description="Number of parallel requests to make to the model. Ignored when no llm_configs are provided; the server then uses its own setting.", ) clientInfo: ClientInfo = Field( default_factory=ClientInfo, @@ -209,6 +213,7 @@ class AnnotateRequest(BaseModel): input_data: InputData llm_configs: list[LLMModelConfig] | None = None uploaded_files: UploadedFiles | None = None + visibility: JobVisibility | None = None class UploadResponse(BaseModel): diff --git a/cytetype/config.py b/cytetype/config.py index 9c9a4ff..f8d7ee3 100644 --- a/cytetype/config.py +++ b/cytetype/config.py @@ -153,6 +153,9 @@ def _log_format(record: "Record") -> str: if record["level"].name == "WARNING": return "⚠️ {message}\n" if record["level"].name == "SUCCESS": + # Respect the NO_COLOR convention (https://no-color.org) for plain-text logs. + if os.environ.get("NO_COLOR"): + return "βœ“ {message}\n" return "\033[92mβœ“\033[0m {message}\n" return "{message}\n" diff --git a/cytetype/core/artifacts.py b/cytetype/core/artifacts.py index d508a99..4ab6ed5 100644 --- a/cytetype/core/artifacts.py +++ b/cytetype/core/artifacts.py @@ -1,4 +1,6 @@ +import contextlib import re +import tempfile from typing import Any, cast from collections.abc import Sequence @@ -13,6 +15,9 @@ from ..config import logger, WRITE_MEM_BUDGET +# Below this many cells an in-memory matrix is small enough that backed mode brings no benefit. +BACKED_MODE_HINT_MIN_CELLS = 100_000 + def _safe_column_dataset_name( source_name: str, @@ -434,7 +439,10 @@ def save_features_matrix( use_row_batch_path = isinstance(mat, CSRDataset) - if not isinstance(mat, (CSRDataset, CSCDataset)): + if ( + not isinstance(mat, (CSRDataset, CSCDataset)) + and n_rows >= BACKED_MODE_HINT_MIN_CELLS + ): logger.warning( "For large datasets, use AnnData backed mode (e.g. sc.read_h5ad(..., backed='r')) " "so `adata.X` is a backed sparse dataset and avoids loading the full matrix in memory.", @@ -472,14 +480,33 @@ def save_features_matrix( raw_cell_batch, min_chunk_size, ) - except Exception: + except Exception as exc: logger.warning( - "Failed to write raw counts group to %s, skipping.", out_file + f"Failed to write raw counts group to {out_file}, skipping: {exc}" ) if "raw" in f: del f["raw"] +def _duckdb_spill_directory( + temp_directory: str | None, +) -> contextlib.AbstractContextManager[str | None]: + if temp_directory is not None: + return contextlib.nullcontext(temp_directory) + # A shared fixed path such as /tmp/duckdb breaks for the second user on a + # multi-user host and is never cleaned up, so default to a private one. + try: + return tempfile.TemporaryDirectory( + prefix="cytetype-duckdb-", ignore_cleanup_errors=True + ) + except OSError as exc: + logger.warning( + f"Could not create a temporary directory for DuckDB ({exc}); " + "using DuckDB's default spill location." + ) + return contextlib.nullcontext(None) + + def save_obs_duckdb( out_file: str, obs_df: pd.DataFrame, @@ -488,31 +515,42 @@ def save_obs_duckdb( table_name: str = "obs", threads: int = 4, memory_limit: str = "4GB", - temp_directory: str = "/tmp/duckdb", + temp_directory: str | None = None, ) -> None: - """Persist adata.obs to a DuckDB table.""" + """Persist adata.obs to a DuckDB table. + + DuckDB spills to ``temp_directory`` when ``memory_limit`` is exceeded. When it + is None, a private directory is created under the system temp location + (honoring ``TMPDIR``) and removed after writing; if that fails, DuckDB's + default location is used. Columns temporarily added to ``obs_df`` are always + removed again. + """ if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", table_name): raise ValueError( "Invalid table_name. Use letters, numbers, and underscores only." ) added_cols: list[str] = [] - if obsm_coordinates is not None and coordinates_key is not None: - col1 = f"__vis_coordinates_{coordinates_key}_1" - col2 = f"__vis_coordinates_{coordinates_key}_2" - obs_df[col1] = obsm_coordinates[:, 0] - obs_df[col2] = obsm_coordinates[:, 1] - added_cols = [col1, col2] - - dd_config: dict[str, Any] = { - "threads": threads, - "memory_limit": memory_limit, - "temp_directory": temp_directory, - } - try: - with duckdb.connect(out_file, config=dd_config) as con: - con.register("obs_df", obs_df) - con.execute(f"CREATE OR REPLACE TABLE {table_name} AS SELECT * FROM obs_df") - finally: - for col in added_cols: - obs_df.drop(columns=col, inplace=True, errors="ignore") + with _duckdb_spill_directory(temp_directory) as spill_path: + try: + if obsm_coordinates is not None and coordinates_key is not None: + col1 = f"__vis_coordinates_{coordinates_key}_1" + col2 = f"__vis_coordinates_{coordinates_key}_2" + added_cols = [col1, col2] + obs_df[col1] = obsm_coordinates[:, 0] + obs_df[col2] = obsm_coordinates[:, 1] + + dd_config: dict[str, Any] = { + "threads": threads, + "memory_limit": memory_limit, + } + if spill_path is not None: + dd_config["temp_directory"] = spill_path + with duckdb.connect(out_file, config=dd_config) as con: + con.register("obs_df", obs_df) + con.execute( + f"CREATE OR REPLACE TABLE {table_name} AS SELECT * FROM obs_df" + ) + finally: + for col in added_cols: + obs_df.drop(columns=col, inplace=True, errors="ignore") diff --git a/cytetype/core/payload.py b/cytetype/core/payload.py index 219ba13..1586f3e 100644 --- a/cytetype/core/payload.py +++ b/cytetype/core/payload.py @@ -17,6 +17,7 @@ def build_annotation_payload( n_parallel_clusters: int, llm_configs: list[dict[str, Any]] | None = None, uploaded_files: dict[str, str | None] | UploadedFiles | None = None, + visibility: str | None = None, ) -> dict[str, Any]: """Build and validate API payload. @@ -31,6 +32,7 @@ def build_annotation_payload( n_parallel_clusters: Number of parallel requests llm_configs: Optional LLM configurations uploaded_files: Optional upload IDs for obs_duckdb / vars_h5 + visibility: Optional report visibility; omitted from the payload when None Returns: Validated payload dict for /annotate @@ -97,6 +99,8 @@ def build_annotation_payload( payload["uploaded_files"] = validated_uploaded_files.model_dump( exclude_none=True ) + if visibility is not None: + payload["visibility"] = visibility return payload diff --git a/cytetype/core/results.py b/cytetype/core/results.py index bb425af..eb593b8 100644 --- a/cytetype/core/results.py +++ b/cytetype/core/results.py @@ -1,10 +1,12 @@ import json import anndata import pandas as pd +from collections.abc import Sequence from typing import Any, Callable from ..config import logger from ..api import get_job_status, fetch_job_results +from ..api.normalization import REVIEWER_CONFIDENCE_LEVELS, renormalize_results def store_job_details( @@ -12,6 +14,7 @@ def store_job_details( job_id: str, api_url: str, results_prefix: str, + visibility: str | None = None, ) -> None: """Store job details in adata.uns for later retrieval. @@ -20,13 +23,17 @@ def store_job_details( job_id: Job ID from API api_url: API base URL results_prefix: Prefix for storage keys + visibility: Requested report visibility; stored only when set """ report_url = f"{api_url}/report/{job_id}" - adata.uns[f"{results_prefix}_jobDetails"] = { + job_details = { "job_id": job_id, "report_url": report_url, "api_url": api_url, } + if visibility is not None: + job_details["visibility"] = visibility + adata.uns[f"{results_prefix}_jobDetails"] = job_details def store_annotations( @@ -40,9 +47,13 @@ def store_annotations( ) -> None: """Store API results and update annotations in the AnnData object. + Adds five categorical obs columns named ``{results_prefix}__{group_key}`` + for annotation, cellOntologyTerm, cellOntologyTermID, cellState and + reviewerConfidence (High, Moderate, Low, or Unknown when missing). + Args: adata: AnnData object to update - result_data: Result dictionary from API + result_data: Normalized result dictionary from ``fetch_job_results`` job_id: Job ID results_prefix: Prefix for storage keys group_key: Column name in adata.obs for grouping @@ -86,6 +97,16 @@ def store_annotations( "", field_getter=lambda item: item.get("cellState", ""), ) + _add_annotation_column( + adata, + result_data, + "reviewerConfidence", + f"{results_prefix}_reviewerConfidence_{group_key}", + clusters, + "Unknown", + field_getter=_reviewer_confidence_label, + categories=[*REVIEWER_CONFIDENCE_LEVELS, "Unknown"], + ) # Check for unannotated clusters if check_unannotated: @@ -95,8 +116,9 @@ def store_annotations( logger.info( f" Annotation labels β†’ adata.obs['{results_prefix}_annotation_{group_key}']\n" f" Cell Ontology terms β†’ adata.obs['{results_prefix}_cellOntologyTerm_{group_key}']\n" - f"Cell Ontology term IDs β†’ adata.obs['{results_prefix}_ontologyTermID_{group_key}']\n" + f"Cell Ontology term IDs β†’ adata.obs['{results_prefix}_cellOntologyTermID_{group_key}']\n" f" Cell states β†’ adata.obs['{results_prefix}_cellState_{group_key}']\n" + f" Reviewer confidence β†’ adata.obs['{results_prefix}_reviewerConfidence_{group_key}']\n" f" Full results β†’ adata.uns['{results_prefix}_results']" ) @@ -107,12 +129,17 @@ def load_local_results( ) -> dict[str, Any] | None: """Load results from adata.uns if they exist. + Results that include ``raw_annotations`` are re-normalized (fill-only), so + results saved by older versions gain the filled legacy fields and the + ``reviewerConfidence``, ``isHeterogeneous`` and ``heterogeneityType`` keys. + Obs columns are not added. + Args: adata: AnnData object to load results from results_prefix: Prefix used when storing results Returns: - Result dictionary if found, None otherwise + A new result dictionary if found (``adata.uns`` is not modified), None otherwise """ results_key = f"{results_prefix}_results" @@ -127,14 +154,15 @@ def load_local_results( try: result = json.loads(stored_results["result"]) if isinstance(result, dict): - return result + return renormalize_results(result) else: logger.warning(f"Expected dict from stored result, got {type(result)}") except (json.JSONDecodeError, TypeError): - # Fallback to dict format (backwards compatibility) + # Fallback to dict format (backwards compatibility). This is the object + # held in adata.uns, so renormalize_results builds new dicts. result = stored_results["result"] if isinstance(result, dict): - return result + return renormalize_results(result) else: logger.warning( f"Expected dict from stored result fallback, got {type(result)}" @@ -252,6 +280,7 @@ def _add_annotation_column( clusters: list[str], default_value: str, field_getter: Callable[[dict[str, Any]], Any] | None = None, + categories: Sequence[str] | None = None, ) -> None: """Add a single annotation column to adata.obs (DRY helper). @@ -263,6 +292,8 @@ def _add_annotation_column( clusters: List of cluster assignments default_value: Default value for missing annotations field_getter: Optional custom function to extract field value + categories: Optional category order; only categories that occur are kept. + Values must be among them. Defaults to the sorted occurring values. """ if field_getter is None: @@ -274,10 +305,19 @@ def field_getter(item: dict[str, Any]) -> Any: for item in result_data.get("annotations", []) } - adata.obs[column_name] = pd.Series( - [field_map.get(cluster_id, default_value) for cluster_id in clusters], - index=adata.obs.index, - ).astype("category") + values = [field_map.get(cluster_id, default_value) for cluster_id in clusters] + dtype: str | pd.CategoricalDtype = "category" + if categories is not None: + occurring = set(values) + dtype = pd.CategoricalDtype([c for c in categories if c in occurring]) + adata.obs[column_name] = pd.Series(values, index=adata.obs.index).astype(dtype) + + +def _reviewer_confidence_label(item: dict[str, Any]) -> str: + confidence = item.get("reviewerConfidence") + if isinstance(confidence, str) and confidence in REVIEWER_CONFIDENCE_LEVELS: + return confidence + return "Unknown" def _check_unannotated_clusters( @@ -297,5 +337,5 @@ def _check_unannotated_clusters( if unannotated_clusters: logger.warning( f"No annotations received from API for cluster IDs: {unannotated_clusters}. " - f"Corresponding cells marked as 'Unknown Annotation'." + f"Corresponding cells marked as 'Unknown'." ) diff --git a/cytetype/main.py b/cytetype/main.py index 17ea043..d02c4f2 100644 --- a/cytetype/main.py +++ b/cytetype/main.py @@ -1,7 +1,7 @@ import sys from importlib.metadata import PackageNotFoundError, version from pathlib import Path -from typing import Any +from typing import Any, Literal, get_args import anndata import numpy as np @@ -14,7 +14,8 @@ from .api.client import ( upload_vars_h5 as upload_vars_h5_file, ) -from .api.exceptions import AuthenticationError +from .api.exceptions import AuthenticationError, AuthorizationError +from .api.schemas import JobVisibility from .config import get_default_api_url, load_credentials, logger, validate_api_url from .core.artifacts import ( _is_integer_valued, @@ -45,6 +46,11 @@ __all__ = ["CyteType"] +# The server gives API keys without an organization license this prefix and refuses +# organization visibility for them. Organization keys are "cyt_" and random characters, +# so about 1 in 4096 of them also starts with it: the prefix alone is not proof. +_PERSONAL_API_KEY_PREFIX = "cyt_p_" + def _get_cytetype_version() -> str | None: try: @@ -53,6 +59,23 @@ def _get_cytetype_version() -> str | None: return None +def _unreadable_credentials_message(error: Exception) -> str: + return ( + f"Saved CyteType credentials could not be read ({error}). Pass `auth_token`, " + "or make sure XDG_CONFIG_HOME or HOME points to a readable configuration " + "directory." + ) + + +def _remove_partial_artifact(*paths: str) -> None: + """Delete files left behind by a failed artifact build.""" + for path in paths: + try: + Path(path).unlink(missing_ok=True) + except OSError as exc: + logger.warning(f"Failed to delete partial artifact {path}: {exc}") + + class CyteType: """CyteType class for characterizing clusters from single-cell RNA-seq data. @@ -60,6 +83,12 @@ class CyteType: The expensive data preparation steps (validation, expression percentage calculation, and marker gene extraction) are performed during initialization, allowing for efficient reuse when making multiple requests with different parameters. + + Initialization also writes the `vars.h5` and `obs.duckdb` artifact files and adds a temporary + `__cytetype_gene_symbols` column (with a numeric suffix if that name is taken) to `adata.var` + holding the cleaned gene symbols. Call `cleanup()` after `run()` to delete the files and remove + the column; a file whose build failed is deleted right away. After `cleanup()`, create a new + `CyteType` object to run another job with artifacts. """ # Type annotations for instance attributes @@ -121,29 +150,42 @@ def __init__( cluster context. Defaults to 10. pcent_batch_size (int, optional): Number of cells to process per chunk when calculating expression percentages. Defaults to 5000. - coordinates_key (str, optional): Key in adata.obsm containing 2D coordinates for - visualization. Must be a 2D array with same number of elements as clusters. - Defaults to "X_umap". + coordinates_key (str, optional): Key in `adata.obsm` with 2D coordinates for the + report's visualization. This key is tried first, then "X_umap", "X_tsne" and + "X_pca", all matched case-insensitively; the first array with `adata.n_obs` + rows and at least 2 columns is used (only its first 2 columns). If none + qualifies, visualization is disabled. Defaults to "X_umap". max_cells_per_group (int, optional): Maximum number of cells to sample per group for visualization. If a group has more cells than this limit, a random sample will be taken. Defaults to 1000. + vars_h5_path (str, optional): Path where the expression artifact (normalized and, + when available, raw counts) is written during initialization and uploaded by + `run()`. A relative path is written to the current working directory. + `cleanup()` deletes the file. Defaults to "vars.h5". + obs_duckdb_path (str, optional): Path where the cell metadata artifact (`adata.obs` + and the visualization coordinates) is written during initialization and + uploaded by `run()`. A relative path is written to the current working + directory. `cleanup()` deletes the file. Defaults to "obs.duckdb". max_metadata_categories (int, optional): Maximum number of unique values a categorical obs column may have to be included in cluster metadata aggregation. Columns with more unique values (e.g. cell barcodes, per-cell IDs) are skipped to avoid excessive memory usage. Defaults to 500. api_url (str | None, optional): URL for the CyteType API endpoint. Defaults to `CYTETYPE_API_URL` when set, otherwise "https://cytetype.nygen.io". - auth_token (str | None, optional): Bearer token for API authentication. If provided, - will be included in the Authorization header as "Bearer {auth_token}". Defaults to None. + auth_token (str | None, optional): API key sent as a Bearer token. When None, the key + saved by `cytetype setup` (or `cytetype login`) for `api_url` is read each time it + is needed, and initialization logs a note if there is none. Defaults to None. label_na (bool, optional): If True, cells with NaN values in the ``group_key`` column are assigned an ``'Unknown'`` cluster label - (or ``'Unknown 2'``, etc. if that label already exists). The original - AnnData object is not modified. If False (default), a ``ValueError`` + (or ``'Unknown 2'``, etc. if that label already exists) in a new + AnnData object, which ``run()`` returns; the labels in the original + object's ``obs`` are not changed. If False (default), a ``ValueError`` is raised instead. Raises: KeyError: If the required keys are missing in `adata.obs` or `adata.uns` - ValueError: If the data format is incorrect or there are validation errors + ValueError: If the data format is incorrect, there are validation errors, or + `api_url` is not a valid server origin """ self.adata = adata self.group_key = group_key @@ -162,6 +204,11 @@ def __init__( self._obs_duckdb_path: str | None = None self._original_gene_symbols_column: str | None = None self._temporary_gene_symbols_column: str | None = None + self._artifacts_cleaned_up = False + + # Report a missing sign-in before the slow preparation, not only at run(). + if not auth_token: + self._check_saved_credentials() try: self.gene_symbols_column = resolve_gene_symbols_column( @@ -188,7 +235,7 @@ def __init__( ) na_label = _generate_unique_na_label(existing_labels) logger.warning( - f"⚠️ Relabeling {n_nan} cells ({pct}%) with NaN values " + f"Relabeling {n_nan} cells ({pct}%) with NaN values " f"in '{group_key}' as '{na_label}'." ) adata = anndata.AnnData( @@ -312,6 +359,7 @@ def __init__( except Exception as exc: logger.warning(f"vars.h5 artifact failed during build: {exc}") self._artifact_build_errors.append(("vars_h5", exc)) + _remove_partial_artifact(vars_h5_path) # Build obs.duckdb try: @@ -332,6 +380,7 @@ def __init__( except Exception as exc: logger.warning(f"obs.duckdb artifact failed during build: {exc}") self._artifact_build_errors.append(("obs_duckdb", exc)) + _remove_partial_artifact(obs_duckdb_path, f"{obs_duckdb_path}.wal") logger.info("Data preparation completed. Ready for submitting jobs.") except Exception: @@ -377,6 +426,7 @@ def _resolve_raw_counts( def _upload_artifacts( self, + auth_token: str, upload_timeout_seconds: int, upload_max_workers: int = 4, ) -> tuple[dict[str, str], list[tuple[str, Exception]]]: @@ -395,7 +445,7 @@ def _upload_artifacts( logger.info("Uploading vars.h5 artifact...") vars_upload = upload_vars_h5_file( self.api_url, - self.auth_token, + auth_token, self._vars_h5_path, timeout=timeout, max_workers=upload_max_workers, @@ -417,7 +467,7 @@ def _upload_artifacts( logger.info("Uploading obs.duckdb artifact...") obs_upload = upload_obs_duckdb_file( self.api_url, - self.auth_token, + auth_token, self._obs_duckdb_path, timeout=timeout, max_workers=upload_max_workers, @@ -438,7 +488,9 @@ def cleanup(self) -> None: Call this after run() completes to remove the vars.h5 and obs.duckdb files from disk and drop the temporary canonical gene-symbol column. - Paths are cleared so repeated calls are safe. + Paths are cleared so repeated calls are safe. A later `run()` raises + unless `require_artifacts=False`; create a new `CyteType` object to + rebuild the artifacts. """ for attr, path in [ ("_vars_h5_path", self._vars_h5_path), @@ -451,14 +503,48 @@ def cleanup(self) -> None: except OSError as exc: logger.warning(f"Failed to delete artifact {path}: {exc}") setattr(self, attr, None) + self._artifacts_cleaned_up = True self._cleanup_temporary_gene_symbols_column() + def _check_saved_credentials(self) -> None: + """Report early when run() will not find a saved API key for ``self.api_url``. + + Only checks: it never raises and does not keep the token, which + ``_resolve_auth_token`` reads when it is needed. + """ + try: + credentials = load_credentials(self.api_url) + except ValueError as error: + logger.warning( + f"{error}. Unless you pass auth_token to run(), run `cytetype logout`, " + "then `cytetype setup`, before calling run()." + ) + return + except (OSError, RuntimeError) as error: + # For example an unreadable file, or no home directory in a container. + logger.warning(_unreadable_credentials_message(error)) + return + + if credentials is None: + # Not a problem when auth_token is passed to run(), as in CI. + logger.info( + f"No saved CyteType sign-in found for {self.api_url}. Unless you pass " + "auth_token to run(), run `cytetype setup` (or `cytetype login`) " + "before calling run()." + ) + def _resolve_auth_token( self, api_url: str, auth_token: str | None = None, ) -> str: + """Return the API key to send to ``api_url``. + + A token given to ``__init__`` or ``run()`` is kept for its API URL. Saved + credentials are read on every call and never stored on the instance, so a + key replaced with ``cytetype setup --force`` is used right away. + """ normalized_api_url = validate_api_url(api_url) if auth_token: self.auth_token = auth_token @@ -468,8 +554,6 @@ def _resolve_auth_token( if self.auth_token and self._auth_token_api_url == normalized_api_url: return self.auth_token - self.auth_token = None - self._auth_token_api_url = None try: credentials = load_credentials(normalized_api_url) except ValueError as error: @@ -477,15 +561,17 @@ def _resolve_auth_token( f"{error}. Run `cytetype logout`, then `cytetype setup`.", error_code="AUTHENTICATION_REQUIRED", ) from error + except (OSError, RuntimeError) as error: + raise AuthenticationError( + _unreadable_credentials_message(error), + error_code="AUTHENTICATION_REQUIRED", + ) from error if credentials is None: raise AuthenticationError( "CyteType sign-in is required. Run `cytetype setup` first.", error_code="AUTHENTICATION_REQUIRED", ) - - self.auth_token = credentials.apiToken - self._auth_token_api_url = normalized_api_url return credentials.apiToken def run( @@ -506,25 +592,26 @@ def run( require_artifacts: bool = True, show_progress: bool = True, override_existing_results: bool = False, + visibility: Literal["private", "org", "public"] | None = None, ) -> anndata.AnnData: """Perform cluster characterization using the CyteType API. Args: - study_context (str, optional): Biological context for the experimental setup. + study_context (str): Biological context for the experimental setup. Required. For example, include information about 'organisms', 'tissues', 'diseases', 'developmental_stages', - 'single_cell_methods', 'experimental_conditions'. Defaults to None. + 'single_cell_methods', 'experimental_conditions'. llm_configs (list[dict[str, Any]] | None, optional): Configuration for the large language models to be used. Each dict must match the LLMModelConfig schema with required 'provider' and 'name' fields. Either 'apiKey' or all AWS credentials ('awsAccessKeyId', 'awsSecretAccessKey', 'awsDefaultRegion') must be provided. Defaults to None, using the API's default model. metadata (dict[str, Any] | None, optional): Custom metadata tags to include in the report header. - Values that look like URLs will be made clickable in the report. - Defaults to None. - n_parallel_clusters (int, optional): Number of parallel requests to make to the model. Maximum is 50. Note than high values can lead to rate limit errors. - Defaults to 2. - results_prefix (str, optional): Prefix for keys added to `adata.obs` and `adata.uns` to - store results. The final annotation column will be - `adata.obs[f"{results_key}_{group_key}"]`. Defaults to "cytetype". + Values must be strings; values that look like URLs will be made clickable in the report. + A "title" key (case-insensitive) also names the job in the dashboard. Defaults to None. + n_parallel_clusters (int, optional): Number of parallel requests to make to the model (1-50). + Note that high values can lead to rate limit errors. Ignored when `llm_configs` is not + provided; the server then uses its own setting. Defaults to 2. + results_prefix (str, optional): Prefix for the keys added to `adata.obs` and `adata.uns` + to store results (see Returns). Defaults to "cytetype". poll_interval_seconds (int, optional): How often (in seconds) to check for results from the API. Defaults to 10. timeout_seconds (int, optional): Maximum time (in seconds) to wait for API results before @@ -532,35 +619,79 @@ def run( api_url (str, optional): URL for the CyteType API endpoint. If not provided, the API URL specified in the initialization will be used. If provided, it will override the API URL specified in the initialization. - auth_token (str | None, optional): Bearer token for API authentication. If not provided, the auth token - specified in the initialization will be used. If provided, it will override the auth token - specified in the initialization. + auth_token (str | None, optional): API key sent as a Bearer token. If provided, it overrides + the token specified in the initialization and is kept for later calls to the same API + URL. If neither is provided, the key saved by `cytetype setup` (or `cytetype login`) + for the API URL is read at each call. Defaults to None. save_query (bool, optional): Whether to save the query to a file. Defaults to True. query_filename (str, optional): Filename for saving the query when save_query is True. Defaults to "query.json". upload_timeout_seconds (int, optional): Socket read timeout used for each artifact upload. Defaults to 3600. upload_max_workers (int, optional): Number of parallel threads used to upload file - chunks. Each worker holds one chunk in memory (~100 MB). Defaults to 4. + chunks. Each worker holds one chunk in memory; the chunk size is set by the server. + Defaults to 4. require_artifacts (bool, optional): Whether to raise an error if artifact building or - uploading fails. When True (default), any artifact failure stops the run. Set to - False to skip artifacts and continue with annotation only. Defaults to True. + uploading fails, or if `cleanup()` already deleted the artifacts. When True (default), + any artifact failure stops the run. Set to False to skip artifacts and continue with + annotation only. Authentication and authorization errors always stop the run. + Defaults to True. show_progress (bool, optional): Whether to display progress updates with spinner and cluster status. Set to False to disable all visual progress output. Defaults to True. override_existing_results (bool, optional): Whether to override existing results with the same results_prefix. If False (default) and results already exist, will raise an error to prevent accidental overwriting. Set to True to explicitly allow overwriting. Defaults to False. + visibility (Literal["private", "org", "public"] | None, optional): Who can view the + report. Defaults to None, which uses the server default ("private"). "org" shares + the report with your organization and needs an organization API key. With a key + that looks like a personal key (starting with `cyt_p_`), a warning is logged + before the artifacts are uploaded. The server rejects "org" for any key without + an organization license, such as a personal key, when the job is submitted, + after the upload. "public" makes the report viewable by anyone with the link, and + any signed-in CyteType user can find it in Chat's report search. Returns: - anndata.AnnData: The input AnnData object, modified in place with the following additions: - - `adata.obs[f"{results_prefix}_{group_key}"]`: Cell type annotations as categorical values - - `adata.uns[f"{results_prefix}_results"]`: Complete API response data and job tracking info + anndata.AnnData: The AnnData object the results were added to. This is the object passed + to `CyteType()`, except when `label_na=True` relabeled NaN cluster labels: then it is + a new object and the obs columns exist only there, so use the returned object. It + gets the following additions: + + - `adata.obs[f"{results_prefix}_annotation_{group_key}"]`: Cell type annotations + - `adata.obs[f"{results_prefix}_cellOntologyTerm_{group_key}"]`: Cell Ontology term names + - `adata.obs[f"{results_prefix}_cellOntologyTermID_{group_key}"]`: Cell Ontology term IDs + - `adata.obs[f"{results_prefix}_cellState_{group_key}"]`: Cell states + - `adata.obs[f"{results_prefix}_reviewerConfidence_{group_key}"]`: Reviewer confidence + ("High", "Moderate" or "Low"; "Unknown" when missing) + - `adata.uns[f"{results_prefix}_results"]`: The job ID and the normalized results + (see `get_results()`) as a JSON string under "result" + - `adata.uns[f"{results_prefix}_jobDetails"]`: The job ID, report URL and API URL, + plus the requested visibility when given + + The obs columns are categorical. Cells in clusters without a result get "Unknown" + ("" for the cell state). Raises: - ValueError: If results with the same prefix already exist and override_existing_results is False - CyteTypeAPIError: If the API request fails or returns invalid data - CyteTypeTimeoutError: If the API does not return results within the specified timeout period + ValueError: If results with the same prefix already exist and override_existing_results + is False, if `api_url` is not a valid server origin, if `visibility` is not a valid + value, if `upload_timeout_seconds` is not positive, or if `cleanup()` deleted the + artifacts and `require_artifacts` is True. + AuthenticationError: If no API key is available (including saved credentials that cannot + be read) or the server rejects it. + pydantic.ValidationError: If `llm_configs`, `metadata`, `n_parallel_clusters` or the + prepared input data are invalid. + RateLimitError: If the server's rate limit for job submissions is exceeded. + QuotaExceededError: If the account's quota is exceeded. + AuthorizationError: If the account may not submit jobs or upload files (for example, an + expired license). + LLMValidationError: If a model in `llm_configs` fails the server's validation. + APIError: For other errors returned by the server. + NetworkError: If the server cannot be reached or returns an unexpected response. + cytetype.api.exceptions.TimeoutError: If a request times out or the job does not complete + within `timeout_seconds` (also importable as `cytetype.CyteTypeTimeoutError`). + JobFailedError: If the job fails on the server. + Exception: With require_artifacts=True, the original exception raised while building or + uploading an artifact. """ # Check for existing results @@ -575,9 +706,34 @@ def run( f" 3. Use annotator.get_results(results_prefix='{results_prefix}') to retrieve existing results" ) + if visibility is not None and visibility not in get_args(JobVisibility): + raise ValueError( + "visibility must be 'private', 'org', 'public' or None, " + f"got {visibility!r}" + ) + + if self._artifacts_cleaned_up: + if require_artifacts: + raise ValueError( + "The artifact files were deleted by cleanup(). Create a new " + "CyteType object to rebuild them, or pass require_artifacts=False " + "to run without them." + ) + logger.warning( + "The artifact files were deleted by cleanup(). Continuing without them." + ) + if api_url is not None: self.api_url = validate_api_url(api_url) - self._resolve_auth_token(self.api_url, auth_token) + token = self._resolve_auth_token(self.api_url, auth_token) + # The server refuses a personal key only at submission, after the upload, so warn + # now. The prefix is not proof, so the run goes on and the server decides. + if visibility == "org" and token.startswith(_PERSONAL_API_KEY_PREFIX): + logger.warning( + "visibility='org' needs an organization API key, and this key looks like " + "a personal key (cyt_p_...). If it is, the server rejects the job after " + "the upload. Use an organization key, or choose 'private' or 'public'." + ) if upload_timeout_seconds <= 0: raise ValueError("upload_timeout_seconds must be greater than 0") @@ -592,9 +748,11 @@ def run( self.expression_percentages, n_parallel_clusters, llm_configs, + visibility=visibility, ) uploaded_file_refs, artifact_errors = self._upload_artifacts( + token, upload_timeout_seconds=upload_timeout_seconds, upload_max_workers=upload_max_workers, ) @@ -602,6 +760,23 @@ def run( payload["uploaded_files"] = uploaded_file_refs if artifact_errors: + # Credential problems are not artifact bugs, and the job submission would + # fail the same way, so raise them with the fix instead of the issue link. + for _, error in artifact_errors: + if isinstance(error, AuthenticationError): + raise AuthenticationError( + f"Artifact upload was rejected: {error.message.rstrip('.')}. " + "Run `cytetype setup --force` to sign in again (or `cytetype " + "login` to save a new key), or pass a valid `auth_token`.", + error_code=error.error_code, + ) from error + if isinstance(error, AuthorizationError): + raise AuthorizationError( + f"Artifact upload was not authorized: {error.message.rstrip('.')}. " + f"Check your account and license at {self.api_url}/dashboard.", + error_code=error.error_code, + ) from error + failed_names = ", ".join(name for name, _ in artifact_errors) if require_artifacts: logger.error( @@ -624,13 +799,15 @@ def run( # Submit job and store details print() - job_id = submit_annotation_job(self.api_url, self.auth_token, payload) - store_job_details(self.adata, job_id, self.api_url, results_prefix) + job_id = submit_annotation_job(self.api_url, token, payload) + store_job_details( + self.adata, job_id, self.api_url, results_prefix, visibility=visibility + ) # Wait for completion result = wait_for_completion( self.api_url, - self.auth_token, + token, job_id, poll_interval_seconds, timeout_seconds, @@ -657,16 +834,35 @@ def get_results( self, results_prefix: str = "cytetype", ) -> dict[str, Any] | None: - """Retrieve the CyteType results from the AnnData object. + """Retrieve the CyteType results for this AnnData object. - If results are not available locally but job details exist, attempts to retrieve - results from the API with a single request (no polling). + Results stored in `adata.uns[f"{results_prefix}_results"]` are returned first. They are + re-normalized, so results saved by older versions also get the filled fields and the + `reviewerConfidence`, `isHeterogeneous` and `heterogeneityType` keys (obs columns are not + added in this case). Otherwise, if `adata.uns[f"{results_prefix}_jobDetails"]` holds a job + ID, the API is asked for the job status and, if the job has completed, for its results + (up to two requests, no polling). Fetched results are written the same way as by `run()`, + to the AnnData object that `run()` returns (`self.adata`; a new object if `label_na=True` + relabeled NaN cluster labels). Args: results_prefix: The prefix used when storing results. Defaults to "cytetype". Returns: - dict[str, Any] | None: The original result dictionary from the API, or None if not found. + dict[str, Any] | None: The normalized results with "annotations", "summary", + "clusterCategories", "studyContext" and "raw_annotations" (the API's per-cluster + results, unchanged). Each annotation has "clusterId", "annotation", "ontologyTerm", + "ontologyTermID", "granularAnnotation", "cellState", "justification", + "supportingMarkers", "conflictingMarkers", "missingExpression", + "unexpectedExpression", "reviewerConfidence" ("High", "Moderate", "Low" or None), + "isHeterogeneous" (bool or None) and "heterogeneityType" (str or None). None if no + results are stored and they could not be fetched (no job details, job not + completed, or the request failed). + + Raises: + ValueError: If the API URL stored in the job details is not a valid server origin. + AuthenticationError: If results must be fetched and no API key is available for the + job's API URL (including saved credentials that cannot be read). """ logger.info(f"Retrieving results with prefix: '{results_prefix}'") diff --git a/cytetype/plotting/dotplot.py b/cytetype/plotting/dotplot.py index e24cbb5..cc8d34b 100644 --- a/cytetype/plotting/dotplot.py +++ b/cytetype/plotting/dotplot.py @@ -6,6 +6,33 @@ from ..core.results import load_local_results +def _gene_symbols(value: object) -> list[str]: + if not isinstance(value, list): + return [] + return [gene for gene in value if isinstance(gene, str) and gene.strip()] + + +def _plot_gene_names( + adata: anndata.AnnData, + gene_symbols: str | None, + use_raw: bool | None, + layer: str | None, +) -> tuple[list[str], str]: + """Return the gene names ``sc.pl.dotplot`` looks up, and where they come from.""" + # Same default as scanpy: use .raw when it exists and no layer is given. + if use_raw is None: + use_raw = layer is None and adata.raw is not None + raw = adata.raw if use_raw else None + var = raw.var if raw is not None else adata.var + source = "adata.raw.var" if raw is not None else "adata.var" + if gene_symbols is None: + return [str(name) for name in var.index], f"{source}_names" + if gene_symbols not in var.columns: + raise KeyError(f"Column '{gene_symbols}' not found in {source}.") + names = [str(value) for value in var[gene_symbols].dropna()] + return names, f"{source}['{gene_symbols}']" + + def marker_dotplot( adata: anndata.AnnData, group_key: str, @@ -33,7 +60,8 @@ def marker_dotplot( Number of top supporting genes to display per cluster. gene_symbols Column in ``adata.var`` containing gene symbols. Forwarded to - ``sc.pl.dotplot`` via the ``gene_symbols`` parameter. + ``sc.pl.dotplot`` via the ``gene_symbols`` parameter; marker genes + are matched to its values instead of ``var_names``. **kwargs Additional keyword arguments forwarded to ``sc.pl.dotplot`` (e.g. ``cmap``, ``use_raw``). @@ -41,6 +69,17 @@ def marker_dotplot( Returns ------- The return value of ``sc.pl.dotplot``. + + Notes + ----- + Rows follow the order of the cluster categories. Labels without a + category (e.g. ``"Unknown"``) are placed last. Genes come from the + structured output's ``keySupportingGenes``, falling back to + ``supportingMarkers`` for jobs without it. They are matched + case-insensitively to the names ``sc.pl.dotplot`` looks up (the server + reports upper-case symbols, e.g. ``CD8A`` for mouse ``Cd8a``). Genes that + are not found are skipped with a warning, and categories left without + genes are dropped. """ results = load_local_results(adata, results_prefix) if results is None: @@ -60,8 +99,28 @@ def marker_dotplot( if not raw_annotations: raise ValueError("No raw_annotations found in CyteType results.") + groupby = f"{results_prefix}_annotation_{group_key}" + if groupby not in adata.obs: + raise KeyError( + f"Annotation column '{groupby}' not found in adata.obs. " + "Check group_key and results_prefix." + ) + # scanpy logs an error and does not plot unless categories_order holds + # exactly the groupby categories, so derive it from them. + obs_categories = list(adata.obs[groupby].astype("category").cat.categories) + label_lookup = set(obs_categories) + + gene_names, gene_source = _plot_gene_names( + adata, gene_symbols, kwargs.get("use_raw"), kwargs.get("layer") + ) + exact_names = set(gene_names) + names_by_folded_case: dict[str, str] = {} + for name in gene_names: + names_by_folded_case.setdefault(name.casefold(), name) + markers: dict[str, list[str]] = {} - categories_order: list[str] = [] + categories_order: list[Any] = [] + unmatched_genes: list[str] = [] for category in cluster_categories: category_name = category["categoryName"] @@ -69,23 +128,65 @@ def marker_dotplot( for cluster_id in category["clusterIds"]: cluster_data = raw_annotations.get(cluster_id) - if cluster_data is None: + latest = ( + cluster_data.get("latest") if isinstance(cluster_data, dict) else None + ) + annotation = latest.get("annotation") if isinstance(latest, dict) else None + if not isinstance(annotation, dict): logger.warning( - "Cluster '{}' listed in clusterCategories but missing from raw_annotations, skipping.", + "Cluster '{}' listed in clusterCategories but has no annotation in raw_annotations, skipping.", cluster_id, ) continue - full_output = cluster_data["latest"]["annotation"]["fullOutput"] - cell_type = full_output["cellType"] - - categories_order.append(cell_type["label"]) - markers[category_name].extend( - cell_type["keySupportingGenes"][:n_top_markers] + full_output = annotation.get("fullOutput") + cell_type = ( + full_output.get("cellType") if isinstance(full_output, dict) else None + ) + if not isinstance(cell_type, dict): + cell_type = {} + + # The groupby column holds annotation.annotation, which the server may + # have refined; cellType.label is the fallback. + for label in (annotation.get("annotation"), cell_type.get("label")): + if isinstance(label, str) and label in label_lookup: + if label not in categories_order: + categories_order.append(label) + break + + genes = _gene_symbols(cell_type.get("keySupportingGenes")) or _gene_symbols( + annotation.get("supportingMarkers") ) + for gene in genes[:n_top_markers]: + matched = ( + gene + if gene in exact_names + else names_by_folded_case.get(gene.casefold()) + ) + if matched is None: + unmatched_genes.append(gene) + else: + markers[category_name].append(matched) markers[category_name] = sorted(set(markers[category_name])) + if unmatched_genes: + missing = list(dict.fromkeys(unmatched_genes)) + logger.warning( + f"{len(missing)} marker genes are not shown because they were not found " + f"in {gene_source}: {', '.join(missing[:5])}" + f"{', ...' if len(missing) > 5 else ''}." + ) + # scanpy would still draw a bracket and label for an empty gene group. + markers = {name: genes for name, genes in markers.items() if genes} + if not markers: + raise ValueError( + f"None of the marker genes were found in {gene_source}. Check " + "gene_symbols (and use_raw), or whether the data contains these genes." + ) + + categories_order += [c for c in obs_categories if c not in categories_order] + try: import scanpy as sc except ImportError: @@ -93,8 +194,6 @@ def marker_dotplot( "scanpy is required for plotting. Install it with: pip install scanpy" ) from None - groupby = f"{results_prefix}_annotation_{group_key}" - return sc.pl.dotplot( adata, markers, diff --git a/docs/AGENTS.md b/docs/AGENTS.md new file mode 100644 index 0000000..60ee33c --- /dev/null +++ b/docs/AGENTS.md @@ -0,0 +1,158 @@ +# Documentation instructions + +These instructions cover documentation sources, notebook execution, and site builds. The +site documents the Python SDK (`cytetype`) and the R package (`CyteTypeR`) together and is +published at `https://docs.nygen.io/CyteType/`. + +- Author executable pages as tracked `.ipynb` files in `docs/source/`. Each notebook is the + page's sole source and contains its prose, code, outputs, and execution metadata. The + executed page today is `getting-started/quickstart.ipynb`. +- Keep prose guides and API references in Markdown. Code in Markdown pages is not executed, + so check every function, argument, default and column name against the source. +- Explain each logical step in notebook code with a comment. Put comments on their own lines, + with a blank line before each comment unless it starts the code cell. +- Give every code cell that draws figures a `figure_descriptions` list in its cell metadata, + with one nonempty description per figure, in output order. The build uses them as alt text + and fails without them. Cell metadata is not part of the code fingerprint, so adding or + editing descriptions needs no re-execution. +- MyST builds guides and notebooks. Sphinx builds only `docs/source/reference/api/`, which + the site serves at `/api/`. +- Import the SDK as `import cytetype` or `from cytetype import ...`, and use `cytetype.*` + autodoc targets, the names under which users import each object. +- Start every page with front matter holding one `description:` line, then a label and one + H1 title. Add every page to the table of contents in `docs/source/myst.yml` and to + `docs/source/llms.txt`, with a one-line summary. + +## Python and R + +Show client code for both clients in synced tabs, Python first, so that a reader's choice +carries across pages: + +````markdown +::::{tab-set} +:::{tab-item} Python +:sync: python +```python +adata = annotator.run(study_context="...") +``` +::: +:::{tab-item} R +:sync: r +```r +obj <- CyteTypeR(obj, prepped, study_context = "...") +``` +::: +:::: +```` + +The pages document CyteTypeR 0.6.0 and the Python client 0.21.0. When a client release is +planned that changes documented behaviour, keep documenting the released version and list each +affected passage under "Update for new CyteTypeR releases" in `docs/MAINTENANCE.md`; update the +listed passages when the release ships. Regenerate `reference/r-api.md` from CyteTypeR's help +pages with `Rscript docs/render_r_reference.R ../CyteTypeR` after each CyteTypeR release. + +Never leave HTML comments in the pages. The site publishes each page's source file, page data +and search index, so comments become public. Track pending work, such as screenshots to add, +in `docs/MAINTENANCE.md` instead. + +## Links + +- Link other pages with relative file links, such as `[Sign in](../getting-started/sign-in.md)` + or `[Quickstart](../getting-started/quickstart.ipynb)`. Relative links must resolve to a + page in `docs/source/`. +- Link the Python API through its Sphinx source with an `.html` (or `.md`) suffix, such as + `[run()](../reference/api/cytetype.html#cytetype.CyteType.run)`. The build points these + links at `/api/cytetype.html#cytetype.CyteType.run`. Anchors are the documented names, + such as `#cytetype.CyteType.run` or `#cytetype.QuotaExceededError`. The absolute form + `/api/.html` also works. +- In the Sphinx pages, link guides relative to `/api/`, such as + `[Python quickstart](../getting-started/quickstart)`. + +## Setup and rendering + +Use Node.js 24 and install the Python tools: + +```bash +uv sync --group docs +make -C docs html +``` + +The site is written to ignored `docs/build/site/_build/html/`. Rendering does not execute code. +Every executable notebook needs a completed execution timestamp, a matching code fingerprint, +executed code cells, and no errors. Prose edits can reuse its outputs. Changed code requires +re-execution and the updated notebook in the same change. For a lightweight source preview, +use `make -C docs preview`; unexecuted pages display a warning that figures and results are +unavailable. Preview builds still reject changed code and incomplete or failed executions. +The render plugin, `docs/render_outputs.py`, turns saved HTML outputs into native page +content: pandas tables, the timing footers, and CyteType's job progress display, which is a +single `
` element and renders as a text block. Any other HTML output fails the build;
+show such objects as text, Markdown or a figure instead. To view a build, serve the output
+directory, for example with
+`python -m http.server -d docs/build/site/_build/html 8000`, and open
+`http://localhost:8000/`.
+
+Pages excluded by `project.exclude` in `docs/source/myst.yml` remain source-only and need no
+saved execution. Builds clear the generated site and API output so excluded pages leave no old
+routes.
+
+Pull requests and the Pages workflow validate and render the notebooks from the checkout
+with `make -C docs html`. CI does not execute them. Missing, failed, or stale
+executions fail the build. GitHub Pages publishes from `master` at
+`https://docs.nygen.io/CyteType/`. The workflow builds with `BASE_URL=/CyteType` and uploads
+the generated site as the Pages artifact; the organization's Pages site serves it under
+`/CyteType/` and owns the root `robots.txt`. Keep rendered site files outside Git.
+
+## Execute notebooks locally
+
+Executing a notebook runs every cell. A page that calls `CyteType.run()`, such as the
+quickstart, uploads data and submits a real annotation job: it needs a saved CyteType sign-in
+on this machine, counts toward the account's job limits, and waits for the job to finish.
+Ask before executing unless the current request already authorizes it.
+
+```bash
+uv run cytetype setup
+make -C docs execute-docs PAGES="quickstart"
+```
+
+`cytetype setup` is needed once per machine; `cytetype login` saves an existing API key
+instead. `PAGES` takes notebook names, with the section folder when a name is ambiguous, such
+as `getting-started/quickstart`. Pages run one at a time in a new kernel of the `uv`
+environment, which uses the checkout's `cytetype`, from a temporary working directory, so
+downloads, `vars.h5`, `obs.duckdb` and `query.json` never land in the repository. The kernel
+runs with `TQDM_DISABLE=1`, so progress bars stay out of the published outputs.
+
+Outputs are published, so the kernel does not get environment variables whose names contain
+`KEY`, `TOKEN`, `SECRET`, `PASSWORD`, `PASSWD` or `CREDENTIAL`; it reads the CyteType sign-in
+from the credentials file under `HOME` or `XDG_CONFIG_HOME`. The runner also refuses to save a
+page whose outputs contain a CyteType API key (`cyt_...`), the saved API key or the value of
+such a variable in your environment. The error names the cell, never the secret.
+
+Each successful execution atomically replaces the same `.ipynb` file in `docs/source/` and
+records `metadata.executed_at` and `metadata.executed_code_sha256`. The latter identifies the
+executed code; do not edit this fingerprint by hand. A failed page leaves its previous notebook
+intact and the command exits with an error after the remaining pages; rerun the failed pages
+directly. Do not edit or render a notebook while it is being executed: the runner keeps your
+edits and discards its result.
+
+The runner records each code cell's UTC start time and elapsed wall time and displays them
+below its output. CPU time is not measured. Keep `%%time` out of notebook cells.
+
+The published outputs show the job's report link. The quickstart says that its report is
+public, so after executing it, set the job's visibility to public in the dashboard. The job
+also records the client version of the checkout, so execute from the released commit when the
+version shown in the report matters.
+
+## Checks
+
+```bash
+uv run --group docs pytest tests/test_docs_structure.py tests/test_docs_render.py \
+    tests/test_docs_execution.py
+make -C docs check-reference
+```
+
+The API check is a nitpicky, warnings-as-errors Sphinx build. Its inventory must document every
+`cytetype.__all__` export under its public `cytetype.` name, and every public `CyteType`
+member exactly once. The structure tests check the table of contents, `llms.txt`, relative
+links, tabs, that pages hold no HTML comments, notebook comments, that notebooks hold no API
+keys, and that the LLM configuration reference matches `cytetype.api.LLMModelConfig`. Inspect the rendered pages after changing execution or
+presentation.
diff --git a/docs/MAINTENANCE.md b/docs/MAINTENANCE.md
new file mode 100644
index 0000000..edf085a
--- /dev/null
+++ b/docs/MAINTENANCE.md
@@ -0,0 +1,38 @@
+# Documentation maintenance
+
+Pending documentation work that is not tracked in the pages themselves. Keep HTML comments
+out of `docs/source/`: the site publishes each page's source, page data and search index,
+so comments become public.
+
+## Update for new CyteTypeR releases
+
+The pages describe CyteTypeR 0.6.0; list here the passages that a future CyteTypeR release changes.
+
+## Screenshots to add
+
+- `getting-started/how-cytetype-works.md`, section "What happens during a job": report live progress showing the six job stages and per-cluster status
+- `getting-started/quickstart-r.md`, section "Plot the CyteType labels on the UMAP.": DimPlot of the PBMC 3k UMAP colored by cytetype_annotation_seurat_clusters
+- `getting-started/sign-in.md`, section "Sign in with your browser": "Authorize CyteType CLI?" page with the account email and the Cancel and Authorize CLI buttons
+- `getting-started/sign-in.md`, section "Remote servers, SSH and headless machines": dashboard API keys page with the "API key created" dialog and its Copy button
+- `guides/prepare-data.md`, section "Cluster IDs and what you see in the report": report cluster card showing the User Label and its Match percentage
+- `guides/running-jobs.md`, section "Follow a job": report while a job runs, with the progress stages and clusters still pending
+- `guides/study-context.md`, section "Metadata tags": report Study Information panel with User-Provided Metadata, a linked tag and the client version
+- `platform/chat.md`, section "Chat": Chat workspace with an attached run, a selected cluster, an answer with a figure and the Outputs panel open
+- `platform/chat.md`, section "Outputs and downloads": Outputs panel with the output menu open (Add to chat, Download CSV, Provenance, Pin, Delete)
+- `platform/dashboard.md`, section "Open the dashboard": dashboard Reports view with the sidebar, the workspace switcher and the My Reports table
+- `platform/dashboard.md`, section "Share by email": Share Reports dialog with one active recipient and one Pending invite
+- `platform/dashboard.md`, section "API keys": API keys page with the Create API key button, the Active and All filters and the key table
+- `platform/dashboard.md`, section "Account": Account page with the Usage and limits card for the personal workspace
+- `platform/mcp.md`, section "Claude (web, desktop and mobile)": Claude custom connector dialog with the CyteType name and server URL
+- `platform/target-analysis.md`, section "Start an analysis": Target analysis dialog with the status line, the Analysis options (JSON) box and the Run analysis button
+- `reference/cli.md`, section "`cytetype setup`": browser page "Authorize CyteType CLI?" with the Cancel and Authorize CLI buttons
+- `results/report.md`, section "The CyteType report": full report with a cluster selected, showing the sidebar, the embedding with the cluster card and the right panel
+- `results/report.md`, section "Follow a running job": report of a running job, with the Study Information progress tracker and pending clusters in the sidebar
+- `results/report.md`, section "Left sidebar": left sidebar in Biological Order with the Filter menu open
+- `results/report.md`, section "Gene search": embedding colored by a gene, with the gene legend and a hover value
+- `results/report.md`, section "Cluster card": cluster card below the embedding, with the confidence badge, the Heterogeneous badge, the ontology link and the User Label match
+- `results/report.md`, section "Toolbar": report toolbar with the Share report popover open
+- `results/report.md`, section "Justification": Justification section with the annotation hierarchy and one expanded Selection Rounds entry
+- `results/report.md`, section "Cell Type Evidence": Cell Type Evidence with the volcano plot and significant-gene table for one nearby cluster
+- `results/report.md`, section "Quality Control": Quality Control with the cell classifier bars and one expanded reviewer in the Reviewer Panel
+- `results/report.md`, section "Study summary": Dataset Overview with the Study Overview text and the Marker Gene Expression dot plot
diff --git a/docs/Makefile b/docs/Makefile
new file mode 100644
index 0000000..19a0893
--- /dev/null
+++ b/docs/Makefile
@@ -0,0 +1,25 @@
+UVRUN ?= uv run --group docs
+
+.PHONY: help html preview check-reference execute-docs
+
+help:
+	@echo 'make html                         Render saved notebook outputs and the API'
+	@echo 'make preview                      Allow clearly labelled unexecuted pages'
+	@echo 'make check-reference              Build and check the Sphinx API'
+	@echo 'make execute-docs PAGES="quickstart"  Execute notebooks locally (real CyteType jobs)'
+
+check-reference:
+	@rm -rf build/api build/api-text
+	@$(UVRUN) sphinx-build -b html -n -W source/reference/api build/api
+	@$(UVRUN) python check_reference_coverage.py
+	@$(UVRUN) sphinx-build -b text -n -W -d build/api/.doctrees source/reference/api build/api-text
+
+html: check-reference
+	@cd .. && $(UVRUN) python -m docs.build_docs
+
+preview: check-reference
+	@cd .. && $(UVRUN) python -m docs.build_docs --preview
+
+execute-docs:
+	$(if $(strip $(PAGES)),,$(error PAGES is required; name the notebooks to execute))
+	@cd .. && $(UVRUN) python -m docs.execute_docs --pages "$(strip $(PAGES))"
diff --git a/docs/build_docs.py b/docs/build_docs.py
new file mode 100644
index 0000000..baf82ef
--- /dev/null
+++ b/docs/build_docs.py
@@ -0,0 +1,689 @@
+"""Render current documentation with saved notebook outputs, without execution."""
+
+import argparse
+import hashlib
+import inspect
+import json
+import os
+import pickle
+import re
+import shutil
+import subprocess
+import tomllib
+from datetime import datetime
+from fnmatch import fnmatchcase
+from html import escape
+from html.parser import HTMLParser
+from importlib import import_module
+from pathlib import Path
+from urllib.parse import urljoin
+from xml.etree import ElementTree
+
+import jupytext
+import nbformat
+import yaml
+
+from docs.render_outputs import preformatted_text
+
+DOCS = Path(__file__).resolve().parent
+SOURCE = DOCS / "source"
+SITE = DOCS / "build" / "site"
+PUBLIC_URL = tomllib.loads((DOCS.parent / "pyproject.toml").read_text())["project"][
+    "urls"
+]["Documentation"].rstrip("/")
+
+
+class ArticleText(HTMLParser):
+    void_tags = {
+        "area",
+        "base",
+        "br",
+        "col",
+        "embed",
+        "hr",
+        "img",
+        "input",
+        "link",
+        "meta",
+        "param",
+        "source",
+        "track",
+        "wbr",
+    }
+
+    def __init__(self, canonical: str, base_url: str, outputs: dict[str, str]):
+        super().__init__()
+        self.canonical = canonical
+        self.base_url = base_url
+        self.outputs = outputs
+        self.article = False
+        self.started = False
+        self.ignored = 0
+        self.pre = False
+        self.link = ""
+        self.parts: list[str] = []
+        self.lines: list[str] = []
+
+    def absolute_url(self, url: str) -> str:
+        if url.startswith("/") and not url.startswith("//"):
+            if self.base_url and url.startswith(f"{self.base_url}/"):
+                url = url[len(self.base_url) :]
+            return f"{PUBLIC_URL}{url}"
+        return urljoin(self.canonical, url)
+
+    def flush(self) -> None:
+        text = "".join(self.parts)
+        if self.pre:
+            text = text.strip("\n")
+        else:
+            text = re.sub(r"[^\S\t]+", " ", text).strip()
+        if text.strip():
+            self.lines.append(text)
+        self.parts.clear()
+
+    def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
+        attributes = dict(attrs)
+        if tag == "article":
+            self.article = True
+        if not self.article:
+            return
+        if self.ignored:
+            if tag not in self.void_tags:
+                self.ignored += 1
+            return
+        output_id = attributes.get("data-mdast-node-id")
+        if output_id in self.outputs:
+            self.flush()
+            self.lines.append(self.outputs.pop(output_id))
+            self.ignored = 1
+            return
+        if (
+            tag in {"script", "style", "nav", "button", "svg"}
+            or "myst-footer-links" in (attributes.get("class") or "").split()
+            or attributes.get("aria-label") == "Link to this Section"
+        ):
+            self.ignored = 1
+            return
+        if tag == "h1":
+            self.started = True
+        if not self.started:
+            return
+        if tag in {
+            "p",
+            "div",
+            "li",
+            "dt",
+            "dd",
+            "tr",
+            "h1",
+            "h2",
+            "h3",
+            "h4",
+            "h5",
+            "h6",
+            "pre",
+            "figure",
+        }:
+            self.flush()
+        if tag == "pre":
+            self.pre = True
+        if tag == "a":
+            self.link = attributes.get("href") or ""
+        if tag == "img":
+            self.parts.append(
+                f" [Image: {attributes.get('alt') or 'Figure'} "
+                f"<{self.absolute_url(attributes['src'])}>] "
+            )
+        if tag == "br":
+            self.parts.append("\n" if self.pre else " ")
+
+    def handle_endtag(self, tag: str) -> None:
+        if not self.article or tag in self.void_tags:
+            return
+        if self.ignored:
+            self.ignored -= 1
+            return
+        if tag == "a" and self.link:
+            self.parts.append(f" <{self.absolute_url(self.link)}>")
+            self.link = ""
+        if tag in {"td", "th"}:
+            self.parts.append("\t")
+        if tag in {
+            "article",
+            "p",
+            "div",
+            "li",
+            "dt",
+            "dd",
+            "tr",
+            "h1",
+            "h2",
+            "h3",
+            "h4",
+            "h5",
+            "h6",
+            "pre",
+            "figure",
+        }:
+            self.flush()
+        if tag == "pre":
+            self.pre = False
+        if tag == "article":
+            self.article = False
+
+    def handle_data(self, data: str) -> None:
+        if self.article and self.started and not self.ignored:
+            self.parts.append(data)
+
+
+def notebook_output_text(node: dict, parser: ArticleText) -> dict[str, str]:
+    outputs = {}
+    if node["type"] == "outputs":
+        parts = []
+        for child in node["children"]:
+            output = child["jupyter_data"]
+            if output.get("metadata", {}).get("cytearc_execution_timing"):
+                continue
+            if output["output_type"] == "stream":
+                parts.append(output["text"])
+            else:
+                data = output["data"]
+                # The render plugin shows a single 
 element, such as CyteType's
+                # job progress, as its text; text/plain only names the HTML object.
+                html = data.get("text/html", {}).get("content")
+                preformatted = None if html is None else preformatted_text(html)
+                if preformatted is not None:
+                    parts.append(preformatted)
+                else:
+                    text = data.get("text/markdown", data["text/plain"])
+                    parts.append(text["content"])
+                parts.extend(
+                    f"Image: {parser.absolute_url(value['path'])}"
+                    for mime, value in data.items()
+                    if mime.startswith("image/")
+                )
+        outputs[node["id"]] = "\n\n".join(parts)
+    else:
+        for child in node.get("children", []):
+            outputs.update(notebook_output_text(child, parser))
+    return outputs
+
+
+def latest_source_commit(repository: Path, dependencies: set[Path]) -> str | None:
+    repository = repository.resolve()
+    paths = {path.resolve() for path in dependencies}
+    if not paths or any(not path.is_relative_to(repository) for path in paths):
+        return None
+    relative = sorted(path.relative_to(repository).as_posix() for path in paths)
+
+    def git(*arguments: str, paths: list[str]) -> str:
+        return subprocess.run(
+            ["git", *arguments, "--", *paths],
+            cwd=repository,
+            capture_output=True,
+            text=True,
+            check=True,
+        ).stdout
+
+    tracked = set(git("ls-files", "-z", paths=relative).rstrip("\0").split("\0"))
+    if tracked != set(relative) or git("status", "--porcelain", paths=relative):
+        return None
+    # Combined path histories can report merges that changed no individual file.
+    dates = [
+        git("log", "-1", "--format=%cI", paths=[path]).strip() for path in relative
+    ]
+    return max(dates, key=datetime.fromisoformat)
+
+
+def api_source_files(doctree) -> set[Path]:
+    paths = set()
+    for node in doctree.findall():
+        if node.tagname != "desc_signature" or not node.get("module"):
+            continue
+        obj = import_module(node["module"])
+        names = node["fullname"].split(".")
+        if node.parent["objtype"] in {"attribute", "data"}:
+            names = names[:-1]
+        for name in names:
+            obj = getattr(obj, name)
+        if isinstance(obj, property):
+            obj = obj.fget
+        source = (
+            obj.__file__
+            if inspect.ismodule(obj)
+            else inspect.getsourcefile(inspect.unwrap(obj))
+        )
+        if source is None:
+            raise ValueError(f"No source file for documented symbol {node['fullname']}")
+        paths.add(Path(source))
+        if inspect.isclass(obj):
+            for member in vars(obj).values():
+                if isinstance(member, classmethod | staticmethod):
+                    member = member.__func__
+                elif isinstance(member, property):
+                    member = member.fget
+                if inspect.isfunction(member):
+                    source = inspect.unwrap(member).__globals__.get("__file__")
+                    if source and Path(source).is_relative_to(DOCS.parent):
+                        paths.add(Path(source))
+    return paths
+
+
+def myst_source_files(document: dict, bibliography: set[Path]) -> set[Path]:
+    source = Path(document["location"].lstrip("/"))
+    paths = {SOURCE / source}
+
+    def include_files(node: dict, containing_file: Path) -> None:
+        if node["type"] == "include":
+            containing_file = (containing_file.parent / node["file"]).resolve()
+            paths.add(
+                SOURCE / containing_file.relative_to(SITE)
+                if containing_file.is_relative_to(SITE)
+                else containing_file
+            )
+        if node["type"] in {"cite", "citeGroup"}:
+            paths.update(bibliography)
+        for child in node.get("children", []):
+            include_files(child, containing_file)
+
+    include_files(document["mdast"], SITE / source)
+    return paths
+
+
+def sitemap_last_modified(html: Path, doctrees: Path) -> dict[str, str]:
+    references = json.loads((html / "myst.xref.json").read_text())["references"]
+    pages = {
+        reference["url"].rstrip("/") + "/": reference["data"]
+        for reference in references
+        if reference["kind"] == "page"
+    }
+    project = yaml.safe_load((SOURCE / "myst.yml").read_text())["project"]
+    bibliography = {SOURCE / path for path in project.get("bibliography", [])}
+    dependencies = {}
+    for route, data_url in pages.items():
+        document = json.loads((html / data_url.lstrip("/")).read_text())
+        dependencies[route] = myst_source_files(document, bibliography)
+
+    environment = pickle.loads((doctrees / "environment.pickle").read_bytes())
+    for source in sorted((html / "api" / "_sources").glob("*.md")):
+        name = source.stem
+        paths = {Path(environment.doc2path(name))}
+        paths.update(
+            Path(environment.srcdir) / path for path in environment.dependencies[name]
+        )
+        paths.update(
+            Path(environment.doc2path(child))
+            for child in environment.toctree_includes.get(name, [])
+        )
+        paths.update(
+            api_source_files(pickle.loads((doctrees / f"{name}.doctree").read_bytes()))
+        )
+        dependencies[f"/api/{name}.html"] = paths
+
+    return {
+        route: date
+        for route, paths in dependencies.items()
+        if (date := latest_source_commit(DOCS.parent, paths)) is not None
+    }
+
+
+def write_discovery_files(
+    html: Path,
+    api_text: Path,
+    *,
+    revision: str,
+    last_modified: dict[str, str] | None = None,
+) -> None:
+    base_url = os.environ.get("BASE_URL", "").rstrip("/")
+    references = json.loads((html / "myst.xref.json").read_text())["references"]
+    pages = {}
+    for reference in references:
+        if reference["kind"] == "page":
+            route = reference["url"].rstrip("/") + "/"
+            if route in pages and pages[route] != reference["data"]:
+                raise ValueError(f"Conflicting documentation routes: {route}")
+            pages[route] = reference["data"]
+    if not pages:
+        raise ValueError(f"No documentation pages in {html / 'myst.xref.json'}")
+
+    exports = {}
+    for route, data_url in pages.items():
+        page = html / route.lstrip("/") / "index.html"
+        canonical = f"{PUBLIC_URL}{route}"
+        document = json.loads((html / data_url.lstrip("/")).read_text())
+        parser = ArticleText(canonical, base_url, {})
+        parser.outputs = notebook_output_text(document["mdast"], parser)
+        parser.feed(page.read_text())
+        if parser.outputs:
+            raise ValueError(
+                f"Notebook outputs missing from {page}: {list(parser.outputs)}"
+            )
+        text = "\n\n".join(parser.lines).strip()
+        if not text:
+            raise ValueError(f"No article content in {page}")
+        export = page.with_name("index.txt")
+        export.write_text(
+            f"Source: {canonical}\nRevision: {revision}\n"
+            f"Structured page: {PUBLIC_URL}{data_url}\n\n{text}\n"
+        )
+        exports[route] = export.relative_to(html).as_posix()
+
+    api_sources = sorted((html / "api" / "_sources").glob("*.md"))
+    if not api_sources:
+        raise ValueError(f"No API source pages in {html / 'api' / '_sources'}")
+    for source in api_sources:
+        route = f"/api/{source.stem}.html"
+        export = html / "api" / f"{source.stem}.txt"
+        text = (api_text / export.name).read_text()
+        if not text.strip():
+            raise ValueError(f"Empty API text: {api_text / export.name}")
+        export.write_text(
+            f"Source: {PUBLIC_URL}{route}\nRevision: {revision}\n\n{text}"
+        )
+        exports[route] = export.relative_to(html).as_posix()
+
+    sitemap = ElementTree.Element(
+        "urlset", xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
+    )
+    for route, export in sorted(exports.items()):
+        canonical = f"{PUBLIC_URL}{route}"
+        page = html / route.lstrip("/")
+        if route.endswith("/"):
+            page /= "index.html"
+        contents = page.read_text()
+        canonical_tag = (
+            f''
+        )
+        canonical_pattern = r"""]*\brel=["']canonical["'])[^>]*>"""
+        contents, count = re.subn(canonical_pattern, canonical_tag, contents)
+        links = (
+            f''
+            f''
+        )
+        if not count:
+            links = canonical_tag + links
+        if contents.count("") != 1:
+            raise ValueError(f"Expected one HTML head in {page}")
+        page.write_text(contents.replace("", links + "", 1))
+        entry = ElementTree.SubElement(sitemap, "url")
+        ElementTree.SubElement(entry, "loc").text = canonical
+        if last_modified and route in last_modified:
+            ElementTree.SubElement(entry, "lastmod").text = last_modified[route]
+    ElementTree.indent(sitemap)
+    ElementTree.ElementTree(sitemap).write(
+        html / "sitemap.xml", encoding="utf-8", xml_declaration=True
+    )
+    (html / "robots.txt").write_text(
+        f"User-agent: *\nAllow: /\n\nSitemap: {PUBLIC_URL}/sitemap.xml\n"
+    )
+
+    def export_link(match: re.Match[str]) -> str:
+        route = "/" if match[1] == "./" else f"/{match[1]}"
+        return f"]({PUBLIC_URL}/{exports[route]})"
+
+    index = re.sub(r"\]\(([^)]+)\)", export_link, (html / "llms.txt").read_text())
+    index = re.sub(
+        r"(?m)^(?=## |\- \[)",
+        f"Documentation revision: {revision}. Links below open plain-text documentation.\n\n",
+        index,
+        count=1,
+    )
+    (html / "llms.txt").write_text(index)
+
+
+def code_fingerprint(notebook: nbformat.NotebookNode) -> str:
+    sources = [cell.source for cell in notebook.cells if cell.cell_type == "code"]
+    encoded = json.dumps(sources, ensure_ascii=False, separators=(",", ":"))
+    return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
+
+
+def validate_execution(notebook: nbformat.NotebookNode, page: Path) -> None:
+    nbformat.validate(notebook)
+    if not notebook.metadata.get("executed_at"):
+        raise ValueError(f"Notebook {page} has no completed execution timestamp")
+    fingerprint = notebook.metadata.get("executed_code_sha256")
+    if not fingerprint:
+        raise ValueError(f"Notebook {page} has no executed-code fingerprint")
+    if fingerprint != code_fingerprint(notebook):
+        raise ValueError(
+            f"Code changed in {page} since execution. Rerun:\n"
+            f'make -C docs execute-docs PAGES="{page.with_suffix("").as_posix()}"'
+        )
+    for index, cell in enumerate(notebook.cells, start=1):
+        if cell.cell_type != "code":
+            continue
+        if any(output.output_type == "error" for output in cell.get("outputs", [])):
+            raise ValueError(f"Notebook {page} has an error in cell {index}")
+        count = cell.get("execution_count")
+        if cell.source.strip() and (type(count) is not int or count < 1):
+            raise ValueError(f"Notebook {page} has an unexecuted cell {index}")
+
+
+def rewrite_includes(text: str, page: Path) -> str:
+    def include_path(match: re.Match[str]) -> str:
+        original = (SOURCE / page.parent / match[2]).resolve()
+        return f"{match[1]}{original.relative_to(SITE / page.parent, walk_up=True)}"
+
+    return re.sub(
+        r"(?m)^((?:`{3,}|:{3,})\{include\}[ \t]+)(\S+)",
+        include_path,
+        text,
+    )
+
+
+def rewrite_api_links(text: str, page: Path) -> str:
+    """Point relative links to the Sphinx API sources at their pages under /api/.
+
+    Sphinx builds ``reference/api/.md`` to ``/api/.html``, beside the MyST
+    routes, so ``../reference/api/cytetype.html#cytetype.CyteType.run`` (or ``.md``)
+    becomes ``/api/cytetype.html#cytetype.CyteType.run``. MyST adds the base URL.
+    """
+    api = (SOURCE / "reference" / "api").resolve()
+
+    def api_link(match: re.Match[str]) -> str:
+        target, hash_sign, fragment = match[1].partition("#")
+        if not target or "://" in target or target.startswith(("/", "mailto:")):
+            return match[0]
+        path = (SOURCE / page.parent / target).resolve()
+        if path.parent != api or path.suffix not in {".html", ".md"}:
+            return match[0]
+        return f"](/api/{path.stem}.html{hash_sign}{fragment})"
+
+    return re.sub(r"\]\(([^)\s]+)\)", api_link, text)
+
+
+def prepare_site(*, preview: bool = False) -> Path:
+    config = yaml.safe_load((SOURCE / "myst.yml").read_text())
+    excludes = config["project"].get("exclude", [])
+
+    def excluded(path: Path) -> bool:
+        relative = path.as_posix()
+        return any(
+            fnmatchcase(relative, pattern)
+            or (pattern.startswith("**/") and fnmatchcase(relative, pattern[3:]))
+            for pattern in excludes
+        )
+
+    def ignore(directory: str, names: list[str]) -> set[str]:
+        relative = Path(directory).relative_to(SOURCE)
+        return shutil.ignore_patterns("_build", "__pycache__", ".ipynb_checkpoints")(
+            directory, names
+        ) | {name for name in names if excluded(relative / name)}
+
+    if SITE.exists():
+        shutil.rmtree(SITE)
+    shutil.copytree(
+        SOURCE,
+        SITE,
+        ignore=ignore,
+    )
+    pages = {
+        path.relative_to(SITE): (
+            nbformat.read(path, as_version=4)
+            if path.suffix == ".ipynb"
+            else jupytext.read(path, fmt="md:myst")
+        )
+        for path in sorted(SITE.rglob("*"))
+        if path.suffix in {".md", ".ipynb"}
+        and "_build" not in path.relative_to(SITE).parts
+    }
+    executable = {
+        page
+        for page, notebook in pages.items()
+        if any(cell.cell_type == "code" for cell in notebook.cells)
+    }
+    for page in executable:
+        if page.suffix != ".ipynb":
+            raise ValueError(
+                f"Executable Markdown page {page} must be an .ipynb notebook. "
+                "Keep its code and outputs in one source file."
+            )
+        if not pages[page].metadata.get("kernelspec"):
+            raise ValueError(
+                f"Executable page {page} has no kernelspec metadata. "
+                "Add the kernel configuration for its execution environment "
+                "before building."
+            )
+    missing = []
+    github = config["project"]["github"].rstrip("/")
+    for page, notebook in pages.items():
+        links = {
+            key: f"{github}/{action}/master/docs/source/{page.as_posix()}"
+            for key, action in (("source_url", "blob"), ("edit_url", "edit"))
+        }
+        staged = SITE / page
+        if page.suffix == ".ipynb":
+            nbformat.validate(notebook)
+            code = [cell for cell in notebook.cells if cell.cell_type == "code"]
+            pristine = (
+                "executed_at" not in notebook.metadata
+                and "executed_code_sha256" not in notebook.metadata
+                and all(
+                    cell.execution_count is None and not cell.outputs for cell in code
+                )
+            )
+            if code and pristine:
+                missing.append(page.as_posix())
+                if preview:
+                    notebook.cells.insert(
+                        0,
+                        nbformat.v4.new_markdown_cell(
+                            ":::{warning} Source preview\n"
+                            "This page has not been executed. Figures and results "
+                            "are not available.\n:::"
+                        ),
+                    )
+            elif code or "executed_code_sha256" in notebook.metadata:
+                validate_execution(notebook, page)
+            for cell in notebook.cells:
+                if cell.cell_type == "markdown":
+                    cell.source = rewrite_api_links(
+                        rewrite_includes(cell.source, page), page
+                    )
+                if preview and pristine:
+                    continue
+                figures = [
+                    output["data"]
+                    for output in cell.get("outputs", [])
+                    if any(mime.startswith("image/") for mime in output.get("data", {}))
+                ]
+                descriptions = cell.metadata.get("figure_descriptions", [])
+                if (
+                    not isinstance(descriptions, list)
+                    or len(descriptions) != len(figures)
+                    or any(
+                        not isinstance(text, str) or not text.strip()
+                        for text in descriptions
+                    )
+                ):
+                    raise ValueError(
+                        f"Figures in {page}, cell {cell.id}, need "
+                        f"{len(figures)} nonempty metadata.figure_descriptions entries"
+                    )
+                for data, description in zip(figures, descriptions, strict=True):
+                    data["text/plain"] = description
+            notebook.metadata.pop("jupytext", None)
+            notebook.metadata.update(links)
+            nbformat.write(notebook, staged)
+        else:
+            text = rewrite_api_links(rewrite_includes(staged.read_text(), page), page)
+            frontmatter = re.match(r"\A---\n(.*?)\n---\n", text, re.DOTALL)
+            metadata = yaml.safe_load(frontmatter[1]) or {} if frontmatter else {}
+            metadata.update(links)
+            body = text[frontmatter.end() :] if frontmatter else text
+            staged.write_text("---\n" + yaml.safe_dump(metadata) + "---\n" + body)
+
+    base_url = os.environ.get("BASE_URL", "").rstrip("/")
+
+    def update_toc(entries: list[dict]) -> None:
+        entries[:] = [
+            entry
+            for entry in entries
+            if "file" not in entry or not excluded(Path(entry["file"]))
+        ]
+        for entry in entries:
+            url = entry.get("url", "")
+            if url.startswith("/") and not url.startswith("//"):
+                entry["url"] = f"{base_url}{url}"
+            update_toc(entry.get("children", []))
+
+    update_toc(config["project"]["toc"])
+
+    # Point paths at the originals relative to the staged site: the theme embeds the
+    # project configuration in every page, so absolute paths would publish build paths.
+    def staged_path(path: str) -> str:
+        return os.path.relpath((SOURCE / path).resolve(), SITE.resolve())
+
+    config["project"]["static_files"] = [
+        staged_path(path) for path in config["project"].get("static_files", [])
+    ]
+    for plugin in config["project"].get("plugins", []):
+        plugin["path"] = staged_path(plugin["path"])
+    (SITE / "myst.yml").write_text(yaml.safe_dump(config, sort_keys=False))
+    if missing:
+        if not preview:
+            raise ValueError(
+                "Saved execution is missing for:\n  "
+                + "\n  ".join(missing)
+                + "\nExecute these pages before building HTML, or use --preview "
+                "for a labelled source preview."
+            )
+        print(
+            "Unexecuted notebooks:\n  " + "\n  ".join(missing),
+            flush=True,
+        )
+    return SITE
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument(
+        "--preview",
+        action="store_true",
+        help="Allow missing executions and label those pages as source previews",
+    )
+    args = parser.parse_args()
+    subprocess.run(
+        ["myst", "build", "--html", "--strict"],
+        cwd=prepare_site(preview=args.preview),
+        check=True,
+    )
+    revision = subprocess.run(
+        ["git", "describe", "--always", "--dirty", "--abbrev=40"],
+        cwd=DOCS.parent,
+        capture_output=True,
+        text=True,
+        check=True,
+    ).stdout.strip()
+    write_discovery_files(
+        SITE / "_build" / "html",
+        DOCS / "build" / "api-text",
+        revision=revision,
+        last_modified=sitemap_last_modified(
+            SITE / "_build" / "html", DOCS / "build" / "api" / ".doctrees"
+        ),
+    )
+
+
+if __name__ == "__main__":
+    main()
diff --git a/docs/check_reference_coverage.py b/docs/check_reference_coverage.py
new file mode 100644
index 0000000..05c37b3
--- /dev/null
+++ b/docs/check_reference_coverage.py
@@ -0,0 +1,96 @@
+"""Check that the built reference documents every public symbol exactly once."""
+
+import inspect
+import sys
+import zlib
+from pathlib import Path
+
+DOCS_ROOT = Path(__file__).resolve().parent
+REPO_ROOT = DOCS_ROOT.parent
+DEFAULT_INVENTORY = DOCS_ROOT / "build" / "api" / "objects.inv"
+
+
+def read_inventory(path: Path) -> list[str]:
+    raw = path.read_bytes()
+    header = raw.split(b"\n", 4)
+    if len(header) < 5:
+        raise SystemExit(f"Malformed inventory: {path}")
+    lines = zlib.decompress(header[4]).decode("utf-8").splitlines()
+    return [line.split(" ", 1)[0] for line in lines if line.strip()]
+
+
+def public_members(cls: type) -> list[str]:
+    """Return the public methods and properties that ``cls`` itself defines."""
+    members = []
+    for name, member in vars(cls).items():
+        if name.startswith("_"):
+            continue
+        if isinstance(member, classmethod | staticmethod):
+            member = member.__func__
+        if isinstance(member, property) or inspect.isfunction(member):
+            members.append(name)
+    return members
+
+
+def member_report(names: list[str]) -> tuple[list[str], list[str]]:
+    from cytetype import CyteType
+
+    missing: list[str] = []
+    duplicated: list[str] = []
+    for cls in (CyteType,):
+        for name in public_members(cls):
+            qualified = f"{cls.__name__}.{name}"
+            hits = [entry for entry in names if entry.endswith(f".{qualified}")]
+            if not hits:
+                missing.append(qualified)
+            elif len(hits) > 1:
+                duplicated.append(f"{qualified}: {', '.join(sorted(hits))}")
+    return missing, duplicated
+
+
+def public_name_report(names: list[str]) -> list[str]:
+    import cytetype
+
+    documented = set(names)
+    expected = [f"cytetype.{name}" for name in cytetype.__all__]
+    return [name for name in expected if name not in documented]
+
+
+def main() -> None:
+    inventory = Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_INVENTORY
+    if not inventory.is_file():
+        raise SystemExit(f"Build the HTML docs first; no inventory at {inventory}")
+    if str(REPO_ROOT) not in sys.path:
+        sys.path.insert(0, str(REPO_ROOT))
+
+    names = read_inventory(inventory)
+    missing_members, duplicated_members = member_report(names)
+    missing_names = public_name_report(names)
+
+    problems: list[str] = []
+    if missing_members:
+        problems.append(
+            "Public CyteType members missing from the reference: "
+            + ", ".join(sorted(missing_members))
+        )
+    if duplicated_members:
+        problems.append(
+            "Public members documented more than once:\n  "
+            + "\n  ".join(sorted(duplicated_members))
+        )
+    if missing_names:
+        problems.append(
+            "Public symbols missing from the reference under their public names: "
+            + ", ".join(sorted(missing_names))
+        )
+    if problems:
+        raise SystemExit("\n".join(problems))
+
+    print(
+        "Reference coverage complete: every cytetype.__all__ symbol is documented under "
+        "its public name, and every public CyteType member is documented exactly once"
+    )
+
+
+if __name__ == "__main__":
+    main()
diff --git a/docs/cli.md b/docs/cli.md
deleted file mode 100644
index 08036f0..0000000
--- a/docs/cli.md
+++ /dev/null
@@ -1,144 +0,0 @@
-# CLI and Authentication
-
-CyteType requires authentication before submitting jobs or fetching remote results. The recommended setup is browser-based sign-in through the CyteType CLI.
-
-## Browser-Based Setup
-
-Run:
-
-```bash
-cytetype setup
-```
-
-The command:
-
-1. Starts a temporary callback server on `127.0.0.1` using an available port.
-2. Opens the CyteType authorization page in your browser and prints the same URL in the terminal.
-3. Verifies the callback state and exchanges the one-time authorization code using PKCE.
-4. Saves the returned API credentials locally.
-
-The API key is not included in the browser URL or printed in the terminal. If the browser does not open automatically, copy the printed URL into a browser. The command times out after five minutes if authorization is not completed.
-
-Running `cytetype setup` again for the same server validates the saved key before reporting the configured account. A valid key does not open another browser. If the key is invalid or inactive, the command fails without changing the saved credentials. Run `cytetype setup --force` to skip validation and authenticate with a new key.
-
-`cytetype get-key` is an alias for `cytetype setup`.
-
-## Use Saved Credentials from Python
-
-No authentication argument is needed after setup:
-
-```python
-from cytetype import CyteType
-
-annotator = CyteType(
-    adata,
-    group_key="leiden",
-)
-adata = annotator.run(study_context="Human PBMC from a healthy donor")
-```
-
-When `run()` starts, CyteType loads the saved API key that matches the selected API server. `get_results()` uses the server saved with the job and resolves credentials for that server when a remote fetch is needed.
-
-If no matching credentials are available, CyteType raises an authentication error and asks you to run `cytetype setup`.
-
-## Use an Existing API Key
-
-If you already have a personal API key, save and validate it with:
-
-```bash
-cytetype login
-```
-
-The key is entered through a hidden prompt. CyteType validates it with the selected server before replacing any saved credentials. A failed login leaves existing credentials unchanged.
-
-## Commands
-
-| Command | Purpose |
-| --- | --- |
-| `cytetype setup [--force]` | Validate saved credentials or sign in through a browser |
-| `cytetype get-key` | Alias for `cytetype setup` |
-| `cytetype login` | Validate and save an existing API key |
-| `cytetype dashboard` | Open the dashboard for the saved server |
-| `cytetype view ` | Open a job report through the saved server's sign-in flow |
-| `cytetype logout` | Delete the locally saved credentials |
-| `cytetype --version` | Print the installed CyteType version |
-| `cytetype --help` | Show all available commands |
-
-`cytetype logout` only removes the local credentials file. Revoke the key from the dashboard if it should no longer be accepted by the server.
-
-## Custom Servers
-
-Pass a server origin directly:
-
-```bash
-cytetype setup --api-url https://cytetype.example.org
-```
-
-Or set the default server for CLI and Python usage:
-
-```bash
-export CYTETYPE_API_URL=https://cytetype.example.org
-cytetype setup
-```
-
-An explicit `--api-url` takes precedence over `CYTETYPE_API_URL`. An explicit `api_url` passed to `CyteType` or `run()` takes precedence in Python.
-
-The API URL must be a server origin containing only the scheme and host, with an optional port. Paths, credentials, query strings, and fragments are rejected. Non-local servers must use HTTPS. `http://localhost` and `http://127.0.0.1` are allowed for local development.
-
-Saved credentials are tied to the selected API origin. Run setup or login against the same origin used by Python:
-
-```python
-annotator = CyteType(
-    adata,
-    group_key="leiden",
-    api_url="https://cytetype.example.org",
-)
-```
-
-CyteType stores one credential set at a time. Completing setup or login for another server replaces the previously saved set.
-
-## Credential Storage
-
-Credentials are stored in `credentials.json` at:
-
-| Platform | Default location |
-| --- | --- |
-| Linux and macOS | `~/.config/cytetype/credentials.json` |
-| Linux and macOS with `XDG_CONFIG_HOME` | `$XDG_CONFIG_HOME/cytetype/credentials.json` |
-| Windows | `%APPDATA%\cytetype\credentials.json` |
-
-On POSIX systems, CyteType sets the directory to mode `0700` and the credentials file to mode `0600`. It also refuses to write into a credentials directory owned by another user.
-
-The file contains the API key in plain JSON so the client can use it. Do not share it, commit it, or copy it into notebooks.
-
-## Direct Tokens for CI, Remote Notebooks, and Managed Environments
-
-Browser-based setup requires the authorization callback to reach `127.0.0.1` in the environment where the CLI is running. It is preferred for local use, but it may not work from a remote notebook, an SSH session without port forwarding, or CI.
-
-If the remote environment has an interactive terminal and you already have an API key, use `cytetype login`. For non-interactive environments, read a token from the platform's secret store and pass it explicitly:
-
-```python
-import os
-
-from cytetype import CyteType
-
-annotator = CyteType(
-    adata,
-    group_key="leiden",
-    api_url="https://cytetype.example.org",
-    auth_token=os.environ["CYTETYPE_API_TOKEN"],
-)
-adata = annotator.run(study_context="Human PBMC from a healthy donor")
-```
-
-`CYTETYPE_API_TOKEN` in this example is a user-managed secret. CyteType does not read it automatically.
-
-Authentication is resolved in this order:
-
-1. An `auth_token` passed directly to `run()`.
-2. An `auth_token` previously supplied to the `CyteType` instance for the same API origin.
-3. Saved CLI credentials matching the API origin.
-
-Tokens are not reused when the API origin changes. Pass a token for the new origin or run CLI setup against that origin.
-
-For common setup failures, see [Troubleshooting](./troubleshooting.md).
diff --git a/docs/configuration.md b/docs/configuration.md
deleted file mode 100644
index 3130de6..0000000
--- a/docs/configuration.md
+++ /dev/null
@@ -1,146 +0,0 @@
-# Configuration
-
-## Initialization Parameters
-```python
-from cytetype import CyteType
-
-annotator = CyteType(
-    adata,
-    group_key="leiden",                 # Required: cluster column name
-    rank_key="rank_genes_groups",       # DE results key
-    gene_symbols_column="gene_symbols", # Gene symbols column
-    n_top_genes=50,                      # Top marker genes per cluster
-    aggregate_metadata=True,             # Aggregate metadata
-    min_percentage=10,                   # Min percentage for cluster context
-    pcent_batch_size=2000,               # Batch size for calculations
-    coordinates_key="X_umap",           # Coordinates key for visualization
-    max_cells_per_group=1000,            # Max cells per group for visualization
-)
-```
-
-## Run Options
-```python
-adata = annotator.run(
-    study_context="Human PBMC from COVID-19 patients",
-    metadata={"experiment_name": "PBMC_COVID", "run_label": "v1"},
-    save_query=True,
-    query_filename="query.json",
-    show_progress=True,
-)
-```
-
-## LLM Configuration
-You can provide your own LLM providers/models:
-```python
-adata = annotator.run(
-    study_context="Human PBMC",
-    llm_configs=[{
-        "provider": "openai",   # one of: anthropic, bedrock, google, groq, mistral, openai, openrouter
-        "name": "gpt-4o-mini",
-        "apiKey": "your-api-key",
-        "baseUrl": "https://api.openai.com/v1",   # optional
-        "modelSettings": {                        # optional
-            "temperature": 0.0,
-            "max_tokens": 4096
-        }
-    }],
-)
-```
-- For local models via Ollama, see [Ollama Integration](./ollama.md)
-
-## Authentication
-
-Before the first annotation, sign in through the CLI:
-
-```bash
-cytetype setup
-```
-
-CyteType opens browser-based authorization and saves a personal API key locally. The Python client automatically loads the saved key for the selected API server when `run()` starts.
-
-For a custom server, use the same origin during setup and in Python:
-
-```bash
-cytetype setup --api-url https://cytetype.example.org
-```
-
-```python
-annotator = CyteType(
-    adata,
-    group_key="leiden",
-    api_url="https://cytetype.example.org",
-)
-adata = annotator.run(study_context="Human PBMC")
-```
-
-For CI or another managed environment, a token can be supplied directly instead of using the local credentials file:
-
-```python
-import os
-
-adata = annotator.run(
-    study_context="Human PBMC",
-    auth_token=os.environ["CYTETYPE_API_TOKEN"],
-)
-```
-
-The environment variable in this example is user-managed. CyteType does not read `CYTETYPE_API_TOKEN` automatically. Do not hard-code API keys in source files or notebooks.
-
-See [CLI and Authentication](./cli.md) for all commands, credential locations, custom server rules, and authentication precedence.
-
-## Artifacts
-
-`run()` automatically builds and uploads two artifact files before submitting an annotation job:
-
-- **`vars.h5`**: a compressed HDF5 file containing the normalized expression matrix (`adata.X`) and variable metadata (`adata.var`). Used by the server for on-demand gene expression lookups during annotation and in the interactive report.
-- **`obs.duckdb`**: a DuckDB database containing the observation metadata (`adata.obs`). Used by the server to power metadata queries and filtering in the interactive report.
-
-Both files are created locally and then uploaded to the CyteType API. The uploaded references are attached to the `/annotate` payload so the server can link them to the job.
-
-### Artifact Parameters
-
-```python
-adata = annotator.run(
-    ...
-    vars_h5_path="vars.h5",        # Local output path for vars artifact
-    obs_duckdb_path="obs.duckdb",   # Local output path for obs artifact
-    upload_timeout_seconds=3600,    # Socket read timeout per upload (seconds)
-    cleanup_artifacts=False,        # Delete local artifact files after run
-    require_artifacts=True,         # Raise on artifact failure (set False to skip)
-)
-```
-
-| Parameter | Default | Description |
-|-----------|---------|-------------|
-| `vars_h5_path` | `"vars.h5"` | Local path where the vars HDF5 file is written |
-| `obs_duckdb_path` | `"obs.duckdb"` | Local path where the obs DuckDB file is written |
-| `upload_timeout_seconds` | `3600` | Socket read timeout for each artifact upload |
-| `cleanup_artifacts` | `False` | Delete local artifact files after run completes or fails |
-| `require_artifacts` | `True` | Raise on artifact build/upload failure. Set to `False` to skip artifacts and continue with annotation only |
-
-### Error Handling
-
-By default (`require_artifacts=True`), any failure during artifact building or uploading stops the run and surfaces the full error. The error message includes a link to report the issue on GitHub.
-
-If you want the annotation to proceed even when artifacts fail (e.g. due to disk space or network issues), set `require_artifacts=False`. Annotation will still work, but the interactive report will not have expression lookups or metadata filtering.
-
-### Memory Recommendation for Large Datasets
-
-For large datasets, open your AnnData object in backed mode to reduce memory usage while building `vars.h5`:
-
-```python
-import scanpy as sc
-
-adata = sc.read_h5ad("input.h5ad", backed="r")
-```
-
-## Advanced
-
-```python
-adata = annotator.run(
-    ...
-    poll_interval_seconds=30,   # How often to poll (default)
-    timeout_seconds=7200,       # Max wait time (default: 2 hours)
-    api_url="https://custom-api.example.com",  # Custom API endpoint if needed
-)
-```
diff --git a/docs/development.md b/docs/development.md
index dc1193d..61cd7b5 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -1,14 +1,18 @@
 # Development
 
 ## Setup
+
 ```bash
-git clone https://github.com/NygenAnalytics/cytetype.git
-cd cytetype
-uv sync --all-extras
-uv run pip install -e .
+git clone https://github.com/NygenAnalytics/CyteType.git
+cd CyteType
+uv sync
 ```
 
-## Testing & Linting
+`uv sync` installs the package in editable mode with the `dev` dependency group. The version comes
+from the git tags (setuptools-scm), so clone with tags to get a meaningful local version.
+
+## Testing and linting
+
 ```bash
 uv run pytest
 uv run ruff check .
@@ -16,8 +20,17 @@ uv run ruff format .
 uv run mypy .
 ```
 
+CI runs the tests on Python 3.12 with `uv sync --locked --all-extras`. After changing dependencies,
+run `uv lock` and commit `uv.lock`.
+
+## Documentation
+
+The documentation site at https://docs.nygen.io/CyteType/ is built from `docs/`. See
+[docs/AGENTS.md](AGENTS.md) for how to build, preview and test it, and how to execute the
+quickstart notebook.
+
 ## Exceptions
-- `CyteTypeError`: Base exception
-- `CyteTypeAPIError`: API communication issues
-- `CyteTypeTimeoutError`: API polling timed out
-- `CyteTypeJobError`: Upstream job reported error 
\ No newline at end of file
+
+The exception classes are listed in the
+[errors reference](https://docs.nygen.io/CyteType/reference/errors/). They are importable from
+`cytetype` and `cytetype.api`.
diff --git a/docs/examples.md b/docs/examples.md
index 53e7bff..ffe9a7b 100644
--- a/docs/examples.md
+++ b/docs/examples.md
@@ -5,13 +5,13 @@ The following are notebooks used to run CyteType on all the single-cell datasets
 
 | Dataset | Links |
 | --- | --- |
-| **Tabula Sapiens** | [Colab](https://colab.research.google.com/drive/1EyQXaruDJBPICUvlUY1E19zxOm_L4_VU?usp=sharing) - [CyteType Report](https://prod.cytetype.nygen.io/report/15332f10-2048-4099-ab1e-baf2ab9e39c3) - [H5ad](https://drive.google.com/file/d/1URo7niPqAo-9HGVH8f3QJfqll9lc8JN_/view?usp=drive_link) |
-| **GTEX v9** | [Colab](https://colab.research.google.com/drive/1uvqG2eVaUuNe66e0_7bp682uCdKx6-KL?usp=sharing) - [CyteType Report](https://prod.cytetype.nygen.io/report/5242f3b8-0078-417d-954e-00d1bb19bdf6) - [H5ad](https://drive.google.com/file/d/1EIpudRyasLUHR6J2v8fdpmBTbCE2__UF/view?usp=drive_link) |
-| **Hypomap** | [Colab](https://colab.research.google.com/drive/1OuTnh8xHoXaINCGcgu_1q-jANwXL8ggF?usp=sharing) - [CyteType Report](https://prod.cytetype.nygen.io/report/3840b662-bacf-4067-b93d-4e57c1f21187) - [H5ad](https://drive.google.com/file/d/1QMvZNdoDlKpOmyguAXSk45-YVz97v4tM/view?usp=drive_link) |
-| **Human Lung Cell Atlas (Core)** | [Colab](https://colab.research.google.com/drive/1FoTD-XzLNDPgYSlgVsxnLwPnWF5YiKny?usp=sharing) - [CyteType Report](https://prod.cytetype.nygen.io/report/6da1458a-392f-4bce-b6c9-4ccb308c8797) - [H5ad](https://drive.google.com/file/d/13O0dyUnwJKLPm8fncRt597S5hs2COsxx/view?usp=drive_link) |
-| **Immune Cell Atlas** | [Colab](https://colab.research.google.com/drive/1Kum9S_kU76QvS__42ABd-Xp1GpH4c9jU?usp=sharing) - [CyteType Report](https://prod.cytetype.nygen.io/report/05ff7629-8f0c-4b95-ac65-30bba9b384c5) - [H5ad](https://drive.google.com/file/d/1iqkC7dG1ovgKsU_8HdZ2eyELIxB0sM3t/view?usp=drive_link) |
-| **Mouse Pancreatic Cell Atlas** | [Colab](https://colab.research.google.com/drive/1fg9W3Lz-E_yAVoqs_6XrQsYkfsfnzFey?usp=sharing) - [CyteType Report](https://prod.cytetype.nygen.io/report/6d248cd2-6b61-4beb-bc58-1d63c7a2fc34) - [H5ad](https://drive.google.com/file/d/19qpRfz4WGuUsRNl0YKuy3YENfHKI6pz-/view?usp=drive_link) |
-| **Diabetic Kidney Disease** | [Colab](https://colab.research.google.com/drive/1kb3urFbl0PEPW4T_ti0DBTAmi5YK_-t1?usp=sharing) - [CyteType Report](https://prod.cytetype.nygen.io/report/0da4eaef-f165-4800-a4e3-c5cf8ec165ad) - [H5ad](https://drive.google.com/file/d/1yZXYlfZHLYcPL18Jy25J4v8kWQYhSsd7/view?usp=drive_link) |
+| **Tabula Sapiens** | [Colab](https://colab.research.google.com/drive/1EyQXaruDJBPICUvlUY1E19zxOm_L4_VU?usp=sharing) - [CyteType Report](https://cytetype.nygen.io/report/15332f10-2048-4099-ab1e-baf2ab9e39c3) - [H5ad](https://drive.google.com/file/d/1URo7niPqAo-9HGVH8f3QJfqll9lc8JN_/view?usp=drive_link) |
+| **GTEX v9** | [Colab](https://colab.research.google.com/drive/1uvqG2eVaUuNe66e0_7bp682uCdKx6-KL?usp=sharing) - [CyteType Report](https://cytetype.nygen.io/report/5242f3b8-0078-417d-954e-00d1bb19bdf6) - [H5ad](https://drive.google.com/file/d/1EIpudRyasLUHR6J2v8fdpmBTbCE2__UF/view?usp=drive_link) |
+| **Hypomap** | [Colab](https://colab.research.google.com/drive/1OuTnh8xHoXaINCGcgu_1q-jANwXL8ggF?usp=sharing) - [CyteType Report](https://cytetype.nygen.io/report/3840b662-bacf-4067-b93d-4e57c1f21187) - [H5ad](https://drive.google.com/file/d/1QMvZNdoDlKpOmyguAXSk45-YVz97v4tM/view?usp=drive_link) |
+| **Human Lung Cell Atlas (Core)** | [Colab](https://colab.research.google.com/drive/1FoTD-XzLNDPgYSlgVsxnLwPnWF5YiKny?usp=sharing) - [CyteType Report](https://cytetype.nygen.io/report/6da1458a-392f-4bce-b6c9-4ccb308c8797) - [H5ad](https://drive.google.com/file/d/13O0dyUnwJKLPm8fncRt597S5hs2COsxx/view?usp=drive_link) |
+| **Immune Cell Atlas** | [Colab](https://colab.research.google.com/drive/1Kum9S_kU76QvS__42ABd-Xp1GpH4c9jU?usp=sharing) - [CyteType Report](https://cytetype.nygen.io/report/05ff7629-8f0c-4b95-ac65-30bba9b384c5) - [H5ad](https://drive.google.com/file/d/1iqkC7dG1ovgKsU_8HdZ2eyELIxB0sM3t/view?usp=drive_link) |
+| **Mouse Pancreatic Cell Atlas** | [Colab](https://colab.research.google.com/drive/1fg9W3Lz-E_yAVoqs_6XrQsYkfsfnzFey?usp=sharing) - [CyteType Report](https://cytetype.nygen.io/report/6d248cd2-6b61-4beb-bc58-1d63c7a2fc34) - [H5ad](https://drive.google.com/file/d/19qpRfz4WGuUsRNl0YKuy3YENfHKI6pz-/view?usp=drive_link) |
+| **Diabetic Kidney Disease** | [Colab](https://colab.research.google.com/drive/1kb3urFbl0PEPW4T_ti0DBTAmi5YK_-t1?usp=sharing) - [CyteType Report](https://cytetype.nygen.io/report/0da4eaef-f165-4800-a4e3-c5cf8ec165ad) - [H5ad](https://drive.google.com/file/d/1yZXYlfZHLYcPL18Jy25J4v8kWQYhSsd7/view?usp=drive_link) |
 
 ## CellHint Organ Atlases
 
@@ -20,22 +20,22 @@ Data was annotated in across three notebooks: [Colab 1/3](https://colab.research
 
 | Tissue | Links |
 | --- | --- |
-| **Blood** | [CyteType report](https://prod.cytetype.nygen.io/report/d0c219b4-2b4a-4b27-bac9-aea280a972f1) |
-| **Bone Marrow** | [CyteType report](https://prod.cytetype.nygen.io/report/bc5099b5-42c2-4ba7-8fdd-bc7b5dd3e84d) |
-| **Heart** | [CyteType report](https://prod.cytetype.nygen.io/report/2ffd6cf1-ec98-43d1-82d7-1fc3e9a11b8c) |
-| **Hippocampus** | [CyteType report](https://prod.cytetype.nygen.io/report/60b98429-2338-4408-a07c-bb60e82ac793) |
-| **Intestine** | [CyteType report](https://prod.cytetype.nygen.io/report/e0a2ca37-872f-489c-8de1-d84434d409fe) |
-| **Kidney** | [CyteType report](https://prod.cytetype.nygen.io/report/7dd1f0ea-7eec-4968-b353-8b52707de5ac) |
-| **Liver** | [CyteType report](https://prod.cytetype.nygen.io/report/a429348c-530a-486c-8980-3349a583b8c4) |
-| **Lung** | [CyteType report](https://prod.cytetype.nygen.io/report/75e41a21-f771-4ebc-829a-82f93529a147) |
-| **Lymph Node** | [CyteType report](https://prod.cytetype.nygen.io/report/b911e212-fe37-4bdc-a7f3-51e9146bf8cc) |
-| **Pancreas** | [CyteType report](https://prod.cytetype.nygen.io/report/b620245a-1ae0-4025-aab7-52ada6dcc6cb) |
-| **Skeletal Muscle** | [CyteType report](https://prod.cytetype.nygen.io/report/3f35a45d-aa1b-42cb-92d2-e739623a402b) |
-| **Spleen** | [CyteType report](https://prod.cytetype.nygen.io/report/4b64ec02-ac01-45b5-84b9-0f16708cbd85) |
+| **Blood** | [CyteType report](https://cytetype.nygen.io/report/d0c219b4-2b4a-4b27-bac9-aea280a972f1) |
+| **Bone Marrow** | [CyteType report](https://cytetype.nygen.io/report/bc5099b5-42c2-4ba7-8fdd-bc7b5dd3e84d) |
+| **Heart** | [CyteType report](https://cytetype.nygen.io/report/2ffd6cf1-ec98-43d1-82d7-1fc3e9a11b8c) |
+| **Hippocampus** | [CyteType report](https://cytetype.nygen.io/report/60b98429-2338-4408-a07c-bb60e82ac793) |
+| **Intestine** | [CyteType report](https://cytetype.nygen.io/report/e0a2ca37-872f-489c-8de1-d84434d409fe) |
+| **Kidney** | [CyteType report](https://cytetype.nygen.io/report/7dd1f0ea-7eec-4968-b353-8b52707de5ac) |
+| **Liver** | [CyteType report](https://cytetype.nygen.io/report/a429348c-530a-486c-8980-3349a583b8c4) |
+| **Lung** | [CyteType report](https://cytetype.nygen.io/report/75e41a21-f771-4ebc-829a-82f93529a147) |
+| **Lymph Node** | [CyteType report](https://cytetype.nygen.io/report/b911e212-fe37-4bdc-a7f3-51e9146bf8cc) |
+| **Pancreas** | [CyteType report](https://cytetype.nygen.io/report/b620245a-1ae0-4025-aab7-52ada6dcc6cb) |
+| **Skeletal Muscle** | [CyteType report](https://cytetype.nygen.io/report/3f35a45d-aa1b-42cb-92d2-e739623a402b) |
+| **Spleen** | [CyteType report](https://cytetype.nygen.io/report/4b64ec02-ac01-45b5-84b9-0f16708cbd85) |
 
 ## Cell Landscapes from BIS
-Cell atlases hosted by [BIS](https://bis.zju.edu.cn/) from various organims and specific tissues
+Cell atlases hosted by [BIS](https://bis.zju.edu.cn/) from various organisms and specific tissues
 
 | Tissue | Links |
 | --- | --- |
-| Human Cell Landscape | [Colab](https://colab.research.google.com/drive/1czLW33FYbnPOmPvnfddsvehXM491UDGq?usp=sharing) - [CyteType Report](https://prod.cytetype.nygen.io/report/581616bf-3c96-4e58-a290-881b40378309) - [Homepage](https://bis.zju.edu.cn/HCL/) |
+| Human Cell Landscape | [Colab](https://colab.research.google.com/drive/1czLW33FYbnPOmPvnfddsvehXM491UDGq?usp=sharing) - [CyteType Report](https://cytetype.nygen.io/report/581616bf-3c96-4e58-a290-881b40378309) - [Homepage](https://bis.zju.edu.cn/HCL/) |
diff --git a/docs/execute_docs.py b/docs/execute_docs.py
new file mode 100644
index 0000000..d2beaf3
--- /dev/null
+++ b/docs/execute_docs.py
@@ -0,0 +1,314 @@
+"""Execute selected documentation notebooks locally and save their outputs.
+
+Pages run one at a time in a fresh kernel, from a temporary working directory, so
+downloads and CyteType's upload files never land in the repository. Executing a page
+that calls ``CyteType.run()`` submits a real annotation job: it needs a saved CyteType
+sign-in (``cytetype setup``), counts toward the account's limits, and waits for the job
+to finish on CyteType's servers.
+
+Outputs are published, so the kernel does not get environment variables whose names
+look secret, and a page whose outputs contain a CyteType API key or another known
+secret is not saved.
+
+Run it from the repository root with the documentation dependencies, for example
+``uv run --group docs python -m docs.execute_docs --pages "quickstart"``, or through
+``make -C docs execute-docs PAGES="quickstart"``.
+"""
+
+import argparse
+import os
+import re
+from collections.abc import Iterator, Mapping
+from datetime import UTC, datetime
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from typing import Any
+
+DOCS_ROOT = Path(__file__).resolve().parent
+SOURCE_DIR = DOCS_ROOT / "source"
+
+# The annotation cell waits for the server job, so allow well beyond the SDK's
+# default two-hour wait before giving up on a page.
+TIMEOUT_SECONDS = 4 * 60 * 60
+
+# Outputs are published, so keep download and upload progress bars and terminal color
+# codes out of them, and render figures inline as PNGs.
+KERNEL_ENVIRONMENT = {
+    "TQDM_DISABLE": "1",
+    "NO_COLOR": "1",
+    "MPLBACKEND": "module://matplotlib_inline.backend_inline",
+}
+
+# The kernel does not get variables with these words in their names, so a page cannot
+# print them. It reads the CyteType sign-in from the credentials file under HOME or
+# XDG_CONFIG_HOME, which it keeps, as it keeps PATH and TMPDIR.
+SECRET_NAME = re.compile("KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL", re.IGNORECASE)
+# Variables that the notebooks need and that are not secret, whatever their names.
+KERNEL_ENVIRONMENT_ALLOWLIST = frozenset({"CYTETYPE_API_URL"})
+# CyteType API keys: organization keys start with cyt_ and personal keys with cyt_p_.
+API_KEY = re.compile(r"cyt_[A-Za-z0-9_-]{16,}")
+# Shorter values, such as flags, are too common to look for in outputs.
+MIN_SECRET_LENGTH = 8
+
+
+def page_path(name: str) -> Path:
+    """Return the source notebook for ``name``, such as ``quickstart``.
+
+    A bare name may also match a notebook in a section folder, such as
+    ``getting-started/quickstart.ipynb``; name the folder when several match.
+    """
+    relative = Path(name)
+    if relative.suffix not in {"", ".ipynb"}:
+        raise ValueError(f"Expected a documentation notebook, not {name}")
+    relative = relative.with_suffix(".ipynb")
+    source = SOURCE_DIR.resolve()
+
+    def notebooks(candidates: list[Path]) -> list[Path]:
+        paths = {candidate.resolve() for candidate in candidates}
+        return sorted(
+            path for path in paths if path.is_relative_to(source) and path.is_file()
+        )
+
+    matches = notebooks([SOURCE_DIR / relative])
+    if not matches and len(relative.parts) == 1:
+        matches = notebooks(list(SOURCE_DIR.glob(f"*/{relative.name}")))
+    if not matches:
+        raise ValueError(f"Unknown documentation page: {name}")
+    if len(matches) > 1:
+        found = ", ".join(path.relative_to(source).as_posix() for path in matches)
+        raise ValueError(f"Ambiguous documentation page {name}: {found}")
+    return matches[0]
+
+
+def add_execution_timings(notebook) -> None:
+    import nbformat
+
+    # The build and its render plugin recognize timing footers by this metadata key,
+    # which the documentation toolchain shares with CyteArc and CyteBase.
+    for index, cell in enumerate(notebook.cells, start=1):
+        if cell.cell_type != "code":
+            continue
+        cell.outputs = [
+            output
+            for output in cell.outputs
+            if output.get("metadata", {}).get("cytearc_execution_timing") is not True
+        ]
+        if not cell.source.strip():
+            continue
+        if type(cell.execution_count) is not int or cell.execution_count < 1:
+            raise ValueError(
+                f"Cannot show execution timing for unexecuted cell {index}"
+            )
+        timing = cell.metadata.get("execution", {})
+        try:
+            started = datetime.fromisoformat(timing["iopub.status.busy"])
+            finished = datetime.fromisoformat(timing["iopub.status.idle"])
+        except (KeyError, TypeError, ValueError) as error:
+            raise ValueError(
+                f"Missing or invalid execution timing in cell {index}"
+            ) from error
+        if started.utcoffset() is None or finished.utcoffset() is None:
+            raise ValueError(
+                f"Execution timing in cell {index} must include a timezone"
+            )
+        elapsed = (finished - started).total_seconds()
+        if elapsed < 0:
+            raise ValueError(f"Execution timing in cell {index} has a negative runtime")
+        stamp = started.astimezone(UTC).isoformat().replace("+00:00", "Z")
+        text = f"Started: {stamp} | Wall time: {elapsed:.3f} s"
+        cell.outputs.append(
+            nbformat.v4.new_output(
+                "display_data",
+                data={"text/plain": text, "text/html": f"{text}"},
+                metadata={"cytearc_execution_timing": True},
+            )
+        )
+
+
+def is_secret_name(name: str) -> bool:
+    """Return whether the environment variable ``name`` may hold a secret."""
+    return name not in KERNEL_ENVIRONMENT_ALLOWLIST and bool(SECRET_NAME.search(name))
+
+
+def kernel_environment(environment: Mapping[str, str]) -> dict[str, str]:
+    """Return ``environment`` without its secret-looking variables, for the kernel."""
+    kept = {
+        name: value for name, value in environment.items() if not is_secret_name(name)
+    }
+    return {**kept, **KERNEL_ENVIRONMENT}
+
+
+def saved_api_key() -> str | None:
+    """Return the API key that ``cytetype setup`` saved, or None if none can be read."""
+    try:
+        from cytetype.config import load_credentials
+
+        credentials = load_credentials()
+    except Exception:
+        return None
+    return None if credentials is None else credentials.apiToken
+
+
+def _strings(value: object) -> Iterator[str]:
+    """Yield the strings in a notebook field: text, lists of lines and JSON data."""
+    if isinstance(value, str):
+        yield value
+    elif isinstance(value, list):
+        # Notebook files can store text as a list of lines.
+        yield "".join(item for item in value if isinstance(item, str))
+        for item in value:
+            if not isinstance(item, str):
+                yield from _strings(item)
+    elif isinstance(value, dict):
+        for key, item in value.items():
+            yield str(key)
+            yield from _strings(item)
+
+
+def output_texts(cell: Mapping[str, Any]) -> Iterator[str]:
+    """Yield the text of a cell's outputs: streams, errors and every data format."""
+    streams: dict[str, list[str]] = {}
+    for output in cell.get("outputs", []):
+        if output.get("output_type") == "stream":
+            chunks = streams.setdefault(output.get("name", ""), [])
+            chunks.extend(_strings(output.get("text")))
+        for field in ("text", "ename", "evalue", "traceback", "data"):
+            yield from _strings(output.get(field))
+    # A stream can arrive split over several outputs, so also check each one whole.
+    for chunks in streams.values():
+        yield "".join(chunks)
+
+
+def check_outputs_for_secrets(
+    notebook: Any, environment: Mapping[str, str] | None = None
+) -> None:
+    """Raise ValueError if outputs hold a secret, naming where but never the secret.
+
+    Looks for CyteType API keys, the values of secret-looking variables in
+    ``environment`` (by default, this process's environment) and the saved API key,
+    which the kernel can read from the credentials file.
+    """
+    if environment is None:
+        environment = os.environ
+    # Map each secret to the description that the error shows instead of it.
+    descriptions = {
+        value: f"the value of {name}"
+        for name, value in environment.items()
+        if is_secret_name(name) and len(value) >= MIN_SECRET_LENGTH
+    }
+    api_key = saved_api_key()
+    if api_key and len(api_key) >= MIN_SECRET_LENGTH:
+        descriptions[api_key] = "the saved CyteType API key"
+    places = [
+        (f"cell {index}", output_texts(cell))
+        for index, cell in enumerate(notebook.cells, start=1)
+    ]
+    # Widget state, such as the text of progress bars, is saved in the metadata.
+    places.append(("the widget state", _strings(notebook.metadata.get("widgets"))))
+    leaks = []
+    for place, texts in places:
+        found = set()
+        for text in texts:
+            if API_KEY.search(text):
+                found.add("a CyteType API key")
+            found.update(
+                description
+                for secret, description in descriptions.items()
+                if secret in text
+            )
+        leaks.extend(f"{description} in {place}" for description in sorted(found))
+    if leaks:
+        raise ValueError(
+            "Refusing to save outputs that contain secrets: " + "; ".join(leaks)
+        )
+
+
+def execute_notebook(source: str) -> str:
+    import nbformat
+    from jupyter_client.kernelspec import KernelSpecManager
+    from jupyter_client.manager import AsyncKernelManager
+    from nbclient import NotebookClient
+
+    notebook = nbformat.reads(source, as_version=4)
+    nbformat.validate(notebook)
+    kernelspec = notebook.metadata.get("kernelspec")
+    if not kernelspec or not any(cell.cell_type == "code" for cell in notebook.cells):
+        raise ValueError("The selected page is not an executable notebook")
+    notebook.metadata.pop("executed_at", None)
+    notebook.metadata.pop("executed_code_sha256", None)
+    for cell in notebook.cells:
+        if cell.cell_type == "code":
+            cell.outputs = []
+            cell.execution_count = None
+            cell.metadata.pop("execution", None)
+    # Start this interpreter's own Python kernel, which runs the checkout's cytetype with
+    # the locked dependencies, even if another python3 kernelspec is installed.
+    kernel_manager = AsyncKernelManager(
+        kernel_name=kernelspec.get("name", ""),
+        kernel_spec_manager=KernelSpecManager(kernel_dirs=[]),
+    )
+    with TemporaryDirectory(prefix="cytetype-docs-") as working_directory:
+        NotebookClient(
+            notebook,
+            km=kernel_manager,
+            timeout=TIMEOUT_SECONDS,
+            allow_errors=False,
+            record_timing=True,
+            resources={"metadata": {"path": working_directory}},
+        ).execute(cleanup_kc=True, env=kernel_environment(os.environ))
+    notebook.metadata["executed_at"] = datetime.now(UTC).isoformat()
+    return nbformat.writes(notebook)
+
+
+def main(pages: list[str]) -> None:
+    import nbformat
+
+    from docs.build_docs import code_fingerprint, validate_execution
+
+    sources = list(dict.fromkeys(page_path(name) for name in pages))
+    if not sources:
+        raise ValueError("Specify affected pages, for example PAGES='quickstart'")
+    snapshots = []
+    for path in sources:
+        source = path.read_text()
+        notebook = nbformat.reads(source, as_version=4)
+        nbformat.validate(notebook)
+        if not notebook.metadata.get("kernelspec") or not any(
+            cell.cell_type == "code" for cell in notebook.cells
+        ):
+            raise ValueError(f"Not an executable notebook: {path}")
+        snapshots.append((path, source, code_fingerprint(notebook)))
+
+    failures = []
+    for path, source, fingerprint in snapshots:
+        relative = path.relative_to(SOURCE_DIR.resolve())
+        print(f"Executing {relative}", flush=True)
+        try:
+            notebook = nbformat.reads(execute_notebook(source), as_version=4)
+            nbformat.validate(notebook)
+            if code_fingerprint(notebook) != fingerprint:
+                raise ValueError("Executed code differs from the source notebook")
+            notebook.metadata["executed_code_sha256"] = fingerprint
+            validate_execution(notebook, relative)
+            add_execution_timings(notebook)
+            check_outputs_for_secrets(notebook)
+            with TemporaryDirectory(prefix=f".{path.stem}-", dir=path.parent) as work:
+                temporary = Path(work) / path.name
+                nbformat.write(notebook, temporary)
+                if path.read_text() != source:
+                    raise ValueError(
+                        "Notebook changed during execution; preserving current edits"
+                    )
+                temporary.replace(path)
+            print(f"Saved {relative}: {notebook.metadata.executed_at}", flush=True)
+        except Exception as error:
+            failures.append(f"{relative}: {error}")
+            print(f"FAILED {relative}: {error}", flush=True)
+    if failures:
+        raise SystemExit("Notebook execution failed:\n" + "\n".join(failures))
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument("--pages", required=True, help="Space-separated notebook names")
+    main(parser.parse_args().pages.split())
diff --git a/docs/ollama.md b/docs/ollama.md
deleted file mode 100644
index 7eb7fd3..0000000
--- a/docs/ollama.md
+++ /dev/null
@@ -1,162 +0,0 @@
-# Tutorial: Expose your local `ollama` LLM to the `CyteType` with `ngrok`
-
-This tutorial will guide you through exposing your locally running `ollama` instance to the CyteType using `ngrok`. This method is incredibly fast and easy, making it perfect for temporary sharing, development, and testing.
-
-- Pros: Extremely simple setup, no domain name required.
-- Cons: The public URL is random and changes every time you restart `ngrok` (on the free plan). The connection dies when you close your terminal.
-
-Prerequisites
-
-- A Linux machine with ollama installed.
-- At least one model pulled (e.g., `ollama pull qwen3:32b`).
-- Access to the command line (CLI).
-
-**NOTE**:
-
-- Make sure the model you want to use supports *tools*. You can check out the [ollama library](https://ollama.com/library) for available models.
-- Make sure you have enough resources available on your machine to run the model you want to use. You may encounter `Error 500: Internal Server Error` if the model is too large for your system's resources.
-
-## Step 1: Configure Ollama to Listen on All Interfaces
-
-By default, for security reasons, `ollama` only listens for requests from your local machine (`127.0.0.1`) at port `11434`. To allow a reverse proxy to connect to it, you need to configure it to listen on all network interfaces (`0.0.0.0`).
-
-Create an override directory for the `ollama` service:
-
-```bash
-sudo mkdir -p /etc/systemd/system/ollama.service.d
-```
-
-Create a new configuration file:
-
-```bash
-sudo nano /etc/systemd/system/ollama.service.d/override.conf
-```
-
-Add the following content to the file:
-
-```bash
-[Service]
-Environment="OLLAMA_HOST=0.0.0.0"
-```
-
-Save the file and exit the editor (in nano, press `Ctrl+O`, then press `Enter` to write, then `CTRL+X` to exit).
-
-Reload the systemd daemon and restart `ollama`:
-
-```bash
-sudo systemctl daemon-reload
-sudo systemctl restart ollama
-```
-
-Verify that Ollama is listening correctly:
-
-Run this command and look for an entry showing `0.0.0.0:11434` or `*:11434`.
-
-```bash
-ss -tlnp | grep 11434
-```
-
-You should see output similar to:
-
-```bash
-LISTEN 0  4096       0.0.0.0:11434       0.0.0.0:*  users:(("ollama",pid=1234,fd=7))
-```
-
-Your `ollama` instance is now ready to accept connections from other devices on your local network and from our reverse proxy.
-
-**NOTE:** If you are using WSL without systemd enabled you will have need to run the following on a *separate terminal*:
-
-```bash
-# OLLAMA_HOST=0.0.0.0 OLLAMA_KEEP_ALIVE=-1 ollama serve
-OLLAMA_HOST=0.0.0.0 ollama serve
-```
-
-The `OLLAMA_KEEP_ALIVE` parameter controls how long the server will keep the model in memory after the response. By default, it's set to 5 minutes, but setting it to `-1` will keep the connection open indefinitely.
-
-## Step 2: Set up and run `ngrok`
-
-Install `ngrok`. The easiest way on Debian/Ubuntu is:
-
-```bash
-curl -sSL https://ngrok-agent.s3.amazonaws.com/ngrok.asc \
-  | sudo tee /etc/apt/trusted.gpg.d/ngrok.asc >/dev/null \
-  && echo "deb https://ngrok-agent.s3.amazonaws.com buster main" \
-  | sudo tee /etc/apt/sources.list.d/ngrok.list \
-  && sudo apt update \
-  && sudo apt install ngrok
-```
-
-Or visit the `ngrok` [download page](https://ngrok.com/downloads/) for other installation methods.
-
-## Step 3: Sign up and Authenticate
-
-Go to the `ngrok` [dashboard](https://dashboard.ngrok.com/) and create a free account.
-
-Find your authtoken on the dashboard.
-
-Add the token to your local configuration (this is a one-time setup):
-
-```bash
-ngrok config add-authtoken 
-```
-
-## Step 4: Start the Tunnel & Get Your Public URL
-
-This command tells `ngrok` to create a public URL and forward all traffic to your local port `11434`.
-
-```bash
-ngrok http 11434
-```
-
-`ngrok` will display a screen with your public URL. It will look something like this:
-
-```bash
-Session Status                online
-Account                       Your Name (Plan: Free)
-Version                       3.x.x
-Region                        United States (us)
-Forwarding                    https://random-string-of-letters.ngrok-free.app -> http://localhost:11434
-
-Connections                   ttl     opn     rt1     rt5     p50     p90
-                              0       0       0.00    0.00    0.00    0.00
-
-```
-
-**NOTE**: Keep this terminal window open! If you close it, the tunnel will stop.
-
-## Step 5: Test Your Public OpenAI-Compatible API
-
-You can now query your local model from anywhere using this public URL. The OpenAI-compatible endpoint is at `/v1`.
-
-Example using `curl` (replace `https://your-ngrok-url.ngrok-free.app` with the URL from the previous step):
-
-```bash
-curl https://your-ngrok-url.ngrok-free.app/v1/chat/completions \
-  -H "Content-Type: application/json" \
-  -d '{
-    "model": "qwen3:32b",
-    "messages": [
-      {
-        "role": "user",
-        "content": "Why is the sky blue?"
-      }
-    ]
-  }'
-```
-
-Now you can use the following base url in your CyteType notebook as follows:
-
-```python
-# Run annotation
-adata = annotator.run(
-    study_context="...",
-    llm_configs=[
-        {
-            "provider": "openai",
-            "name": "qwen3:32b",
-            "apiKey": "ollama",
-            "baseUrl": "https://your-ngrok-url.ngrok-free.app/v1",
-        }
-    ]
-)
-``` 
\ No newline at end of file
diff --git a/docs/render_outputs.py b/docs/render_outputs.py
new file mode 100755
index 0000000..d2948e2
--- /dev/null
+++ b/docs/render_outputs.py
@@ -0,0 +1,163 @@
+#!/usr/bin/env -S uv run --no-sync python
+"""Render saved notebook HTML outputs as native MyST nodes."""
+
+from html.parser import HTMLParser
+import json
+import sys
+
+
+class OutputTable(HTMLParser):
+    def __init__(self) -> None:
+        super().__init__()
+        self.root = {"type": "root", "children": []}
+        self.stack = [("root", self.root)]
+        self.style = False
+
+    def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
+        if tag == "style":
+            self.style = True
+            return
+        if tag in {"div", "thead", "tbody"}:
+            return
+        kinds = {
+            "table": "table",
+            "tr": "tableRow",
+            "th": "tableCell",
+            "td": "tableCell",
+            "p": "paragraph",
+        }
+        if tag not in kinds:
+            raise ValueError(f"Unsupported saved table element: {tag}")
+        node = {"type": kinds[tag], "children": []}
+        if tag in {"th", "td"}:
+            node["header"] = tag == "th"
+            attributes = dict(attrs)
+            for name in ("rowspan", "colspan"):
+                if name in attributes:
+                    value = int(attributes[name])
+                    if value < 1:
+                        raise ValueError(f"Invalid table {name}: {value}")
+                    node[name] = value
+        self.stack[-1][1]["children"].append(node)
+        self.stack.append((tag, node))
+
+    def handle_endtag(self, tag: str) -> None:
+        if tag == "style":
+            self.style = False
+            return
+        if tag in {"div", "thead", "tbody"}:
+            return
+        if len(self.stack) == 1 or self.stack[-1][0] != tag:
+            raise ValueError(f"Unexpected saved table closing element: {tag}")
+        self.stack.pop()
+
+    def handle_data(self, data: str) -> None:
+        if self.style or not data.strip():
+            return
+        if self.stack[-1][0] not in {"th", "td", "p"}:
+            raise ValueError("Saved table text is outside a cell or summary")
+        self.stack[-1][1]["children"].append({"type": "text", "value": data.strip()})
+
+    def close(self) -> None:
+        super().close()
+        if len(self.stack) != 1 or self.style:
+            raise ValueError("Unclosed saved table element")
+        if not any(node["type"] == "table" for node in self.root["children"]):
+            raise ValueError("Saved HTML output has no table")
+
+
+class Preformatted(HTMLParser):
+    """Collect the text of HTML that is exactly one ``
`` element with only text.
+
+    CyteType shows job progress in notebooks as ``
`` HTML that it updates in place.
+    """
+
+    def __init__(self) -> None:
+        super().__init__()
+        self.state = "before"
+        self.parts: list[str] = []
+
+    def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
+        self.state = "inside" if self.state == "before" and tag == "pre" else "other"
+
+    def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
+        self.state = "other"
+
+    def handle_endtag(self, tag: str) -> None:
+        self.state = "after" if self.state == "inside" and tag == "pre" else "other"
+
+    def handle_data(self, data: str) -> None:
+        if self.state == "inside":
+            self.parts.append(data)
+        elif data.strip():
+            self.state = "other"
+
+    def handle_comment(self, data: str) -> None:
+        self.state = "other"
+
+    def handle_decl(self, decl: str) -> None:
+        self.state = "other"
+
+    def handle_pi(self, data: str) -> None:
+        self.state = "other"
+
+    def unknown_decl(self, data: str) -> None:
+        self.state = "other"
+
+
+def preformatted_text(html: str | list[str]) -> str | None:
+    """Return the unescaped text of a single ``
`` element, or None otherwise."""
+    parser = Preformatted()
+    parser.feed("".join(html))
+    parser.close()
+    if parser.state != "after":
+        return None
+    text = "".join(parser.parts)
+    # Browsers ignore a newline that directly follows the 
 start tag.
+    return text.removeprefix("\n")
+
+
+def render_outputs(node: dict) -> None:
+    for child in node.get("children", []):
+        render_outputs(child)
+    if node.get("type") != "output":
+        return
+    output = node["jupyter_data"]
+    data = output.get("data", {})
+    if "text/html" not in data:
+        return
+    if output.get("metadata", {}).get("cytearc_execution_timing"):
+        text = data["text/plain"]
+        children = [
+            {
+                "type": "paragraph",
+                "children": [{"type": "text", "value": "".join(text)}],
+            }
+        ]
+    elif (text := preformatted_text(data["text/html"])) is not None:
+        children = [{"type": "code", "lang": "text", "value": text}]
+    else:
+        parser = OutputTable()
+        parser.feed("".join(data["text/html"]))
+        parser.close()
+        children = parser.root["children"]
+    node["type"] = "block"
+    node["children"] = children
+
+
+def main() -> None:
+    if len(sys.argv) == 1:
+        result = {
+            "name": "Saved notebook outputs",
+            "transforms": [{"name": "saved-outputs", "stage": "document"}],
+        }
+    elif sys.argv[1:] == ["--transform", "saved-outputs"]:
+        result = json.load(sys.stdin)
+        render_outputs(result)
+    else:
+        raise ValueError(f"Unknown plugin arguments: {sys.argv[1:]}")
+    print(json.dumps(result))
+
+
+if __name__ == "__main__":
+    main()
diff --git a/docs/render_r_reference.R b/docs/render_r_reference.R
new file mode 100644
index 0000000..a41e32e
--- /dev/null
+++ b/docs/render_r_reference.R
@@ -0,0 +1,489 @@
+#!/usr/bin/env Rscript
+# Render the R function reference, docs/source/reference/r-api.md, from the
+# help pages (man/*.Rd) of the CyteTypeR package.
+#
+# Usage, from the root of the CyteType repository:
+#
+#   Rscript docs/render_r_reference.R [path/to/CyteTypeR]
+#
+# The path defaults to ../CyteTypeR, a checkout of CyteTypeR next to this
+# repository. Run roxygen2::roxygenise() in CyteTypeR first if its R sources
+# changed. The script uses base R only (tools::parse_Rd) and installs nothing.
+# It writes an H2 section for each group in GROUPS below and, under it, an H3
+# section for each of the group's exported functions; add new exported
+# functions to a group there.
+
+GROUPS <- list(
+  list(
+    title = "Prepare and run",
+    label = "r-api-prepare-and-run",
+    intro = paste(
+      "Prepare a Seurat object, submit the annotation job and wait for the",
+      "results, then delete the uploaded files."
+    ),
+    functions = c("PrepareCyteTypeR", "CyteTypeR", "CleanUpArtifacts")
+  ),
+  list(
+    title = "Results",
+    label = "r-api-results",
+    intro = paste(
+      "Read the results stored in a Seurat object, or fetch the results of a",
+      "job by its ID."
+    ),
+    functions = c("GetResults")
+  ),
+  list(
+    title = "Sign-in and the cytetyper command",
+    label = "r-api-sign-in",
+    intro = paste(
+      "Save an API key, open the dashboard or a job's report, and install the",
+      "`cytetyper` terminal command. Each `cytetyper` command calls one of",
+      "these functions (see the [Command-line reference](cli.md))."
+    ),
+    functions = c(
+      "SetupCyteTypeR",
+      "LoginCyteTypeR",
+      "LogoutCyteTypeR",
+      "OpenCyteTypeDashboard",
+      "ViewCyteTypeJob",
+      "InstallCyteTypeRCli"
+    )
+  ),
+  list(
+    title = "Errors",
+    label = "r-api-errors",
+    intro = paste(
+      "The condition that CyteTypeR signals when the status or the results of",
+      "a job cannot be fetched (see [Errors and error codes](errors.md))."
+    ),
+    functions = c("cytetype_api_error")
+  )
+)
+
+DESCRIPTION_LINE <- paste(
+  "description: The exported functions of the CyteTypeR R package, with their",
+  "usage, arguments, return values and examples, generated from the package's",
+  "help pages."
+)
+
+# Paths ---------------------------------------------------------------------
+
+script_path <- function() {
+  file_arg <- grep("^--file=", commandArgs(trailingOnly = FALSE), value = TRUE)
+  if (length(file_arg) == 0L) {
+    return(normalizePath("docs/render_r_reference.R", mustWork = FALSE))
+  }
+  normalizePath(sub("^--file=", "", file_arg[[1L]]), mustWork = TRUE)
+}
+
+repository_root <- dirname(dirname(script_path()))
+arguments <- commandArgs(trailingOnly = TRUE)
+package_path <- if (length(arguments) >= 1L) {
+  arguments[[1L]]
+} else {
+  file.path(repository_root, "..", "CyteTypeR")
+}
+package_path <- normalizePath(package_path, mustWork = FALSE)
+if (!file.exists(file.path(package_path, "DESCRIPTION")) ||
+    !dir.exists(file.path(package_path, "man"))) {
+  stop("No CyteTypeR package (DESCRIPTION and man/) at ", package_path, call. = FALSE)
+}
+output_path <- file.path(repository_root, "docs", "source", "reference", "r-api.md")
+
+description <- read.dcf(file.path(package_path, "DESCRIPTION"), fields = c("Package", "Version"))
+if (!identical(unname(description[1L, "Package"]), "CyteTypeR")) {
+  stop(package_path, " is not the CyteTypeR package", call. = FALSE)
+}
+package_version <- unname(description[1L, "Version"])
+
+# Exported functions from NAMESPACE, in either roxygen2 layout.
+namespace_text <- paste(readLines(file.path(package_path, "NAMESPACE"), warn = FALSE), collapse = "\n")
+export_blocks <- regmatches(namespace_text, gregexpr("(?m)^export\\(([^)]*)\\)", namespace_text, perl = TRUE))[[1L]]
+exports <- unlist(lapply(export_blocks, function(block) {
+  inner <- sub("^export\\(", "", sub("\\)$", "", block))
+  trimws(gsub("[\"`]", "", strsplit(inner, ",", fixed = TRUE)[[1L]]))
+}))
+exports <- exports[nzchar(exports)]
+
+# Rd helpers -----------------------------------------------------------------
+
+tag <- function(node) {
+  value <- attr(node, "Rd_tag")
+  if (is.null(value)) "" else value
+}
+
+section <- function(rd, name) {
+  matches <- Filter(function(node) identical(tag(node), name), rd)
+  if (length(matches) == 0L) NULL else matches[[1L]]
+}
+
+sections <- function(rd, name) {
+  Filter(function(node) identical(tag(node), name), rd)
+}
+
+anchor <- function(name) {
+  paste0("r-api-", gsub("^-+|-+$", "", gsub("[^a-z0-9]+", "-", tolower(name))))
+}
+
+# Plain text of an Rd fragment, as written (for code, usage and examples).
+plain_text <- function(node) {
+  node_tag <- tag(node)
+  if (node_tag %in% c("TEXT", "RCODE", "VERB")) {
+    return(paste(as.character(node), collapse = ""))
+  }
+  if (node_tag == "COMMENT") {
+    return("")
+  }
+  if (node_tag %in% c("\\dots", "\\ldots")) {
+    return("...")
+  }
+  if (node_tag %in% c("\\dontshow", "\\testonly")) {
+    return("")
+  }
+  if (node_tag == "\\method" || node_tag == "\\S3method") {
+    return(paste0(plain_text(node[[1L]])))
+  }
+  if (node_tag == "\\R") {
+    return("R")
+  }
+  if (is.list(node)) {
+    return(paste(vapply(node, plain_text, character(1)), collapse = ""))
+  }
+  paste(as.character(node), collapse = "")
+}
+
+escape_text <- function(text) {
+  text <- gsub("\\", "\\\\", text, fixed = TRUE)
+  text <- gsub("([`*_\\[\\]<>$|~])", "\\\\\\1", text, perl = TRUE)
+  gsub("(?m)^([ \t]*)#", "\\1\\\\#", text, perl = TRUE)
+}
+
+code_span <- function(text) {
+  text <- gsub("\\s+", " ", text)
+  fence <- if (grepl("`", text, fixed = TRUE)) "``" else "`"
+  paste0(fence, text, fence)
+}
+
+link_target <- function(node) {
+  option <- attr(node, "Rd_option")
+  topic <- plain_text(node)
+  if (!is.null(option)) {
+    option <- plain_text(option)
+    if (startsWith(option, "=")) {
+      topic <- substring(option, 2L)
+    } else {
+      return(NULL)
+    }
+  }
+  topic <- sub("\\(\\)$", "", topic)
+  if (topic %in% exports) topic else NULL
+}
+
+# Inline Markdown of an Rd fragment. Newlines are kept so that paragraphs can
+# be split later; lists are flattened when `flatten_lists` is TRUE.
+inline <- function(node, flatten_lists = FALSE) {
+  node_tag <- tag(node)
+  recurse <- function(x) {
+    paste(vapply(x, inline, character(1), flatten_lists = flatten_lists), collapse = "")
+  }
+  switch(
+    node_tag,
+    "TEXT" = escape_text(paste(as.character(node), collapse = "")),
+    "RCODE" = ,
+    "VERB" = escape_text(paste(as.character(node), collapse = "")),
+    "COMMENT" = "",
+    "\\code" = ,
+    "\\verb" = ,
+    "\\samp" = ,
+    "\\file" = ,
+    "\\env" = ,
+    "\\option" = ,
+    "\\command" = ,
+    "\\kbd" = {
+      links <- Filter(function(child) identical(tag(child), "\\link"), node)
+      text <- plain_text(node)
+      target <- if (length(links) == 1L) link_target(links[[1L]]) else NULL
+      if (is.null(target)) {
+        code_span(text)
+      } else {
+        paste0("[", code_span(text), "](#", anchor(target), ")")
+      }
+    },
+    "\\link" = {
+      target <- link_target(node)
+      text <- plain_text(node)
+      if (is.null(target)) escape_text(text) else paste0("[", escape_text(text), "](#", anchor(target), ")")
+    },
+    "\\url" = {
+      url <- trimws(plain_text(node))
+      paste0("[", escape_text(url), "](", url, ")")
+    },
+    "\\href" = paste0("[", recurse(node[[2L]]), "](", trimws(plain_text(node[[1L]])), ")"),
+    "\\email" = {
+      email <- trimws(plain_text(node))
+      paste0("[", escape_text(email), "](mailto:", email, ")")
+    },
+    "\\strong" = ,
+    "\\bold" = paste0("**", recurse(node), "**"),
+    "\\emph" = ,
+    "\\var" = ,
+    "\\dfn" = paste0("*", recurse(node), "*"),
+    "\\sQuote" = paste0("'", recurse(node), "'"),
+    "\\dQuote" = paste0("\"", recurse(node), "\""),
+    "\\pkg" = ,
+    "\\acronym" = ,
+    "\\abbr" = ,
+    "\\cite" = recurse(node),
+    "\\dots" = ,
+    "\\ldots" = "...",
+    "\\R" = "R",
+    "\\cr" = "\n",
+    "\\enc" = recurse(node[[1L]]),
+    "\\eqn" = code_span(plain_text(node[[length(node)]])),
+    "\\if" = {
+      format <- plain_text(node[[1L]])
+      if (grepl("html|text", format)) recurse(node[[2L]]) else ""
+    },
+    "\\ifelse" = {
+      format <- plain_text(node[[1L]])
+      if (grepl("html|text", format)) recurse(node[[2L]]) else recurse(node[[3L]])
+    },
+    "\\out" = plain_text(node),
+    "\\Sexpr" = ,
+    "\\figure" = ,
+    "\\special" = "",
+    "\\itemize" = ,
+    "\\enumerate" = ,
+    "\\describe" = {
+      items <- list_items(node)
+      if (flatten_lists) {
+        paste0(" ", paste(items, collapse = "; "), " ")
+      } else {
+        stop("Block element inside inline content: ", node_tag, call. = FALSE)
+      }
+    },
+    if (is.list(node)) recurse(node) else escape_text(paste(as.character(node), collapse = ""))
+  )
+}
+
+squash <- function(text) {
+  trimws(gsub("[ \t]*\n[ \t]*", " ", gsub("[ \t]+", " ", text)))
+}
+
+# The items of \itemize, \enumerate or \describe as inline Markdown.
+list_items <- function(node) {
+  node_tag <- tag(node)
+  if (node_tag == "\\describe") {
+    items <- Filter(function(child) identical(tag(child), "\\item"), node)
+    return(vapply(items, function(item) {
+      paste0("**", squash(inline(item[[1L]], TRUE)), "**: ", squash(inline(item[[2L]], TRUE)))
+    }, character(1)))
+  }
+  items <- list()
+  current <- NULL
+  for (child in node) {
+    if (identical(tag(child), "\\item")) {
+      if (!is.null(current)) items[[length(items) + 1L]] <- current
+      current <- list()
+    } else if (!is.null(current)) {
+      current[[length(current) + 1L]] <- child
+    }
+  }
+  if (!is.null(current)) items[[length(items) + 1L]] <- current
+  vapply(items, function(item) {
+    squash(paste(vapply(item, inline, character(1), flatten_lists = TRUE), collapse = ""))
+  }, character(1))
+}
+
+# Block Markdown (paragraphs, lists and code blocks) of an Rd section.
+blocks <- function(node) {
+  output <- character()
+  buffer <- ""
+  flush <- function() {
+    paragraphs <- strsplit(buffer, "\n[ \t]*\n", perl = TRUE)[[1L]]
+    paragraphs <- vapply(paragraphs, squash, character(1), USE.NAMES = FALSE)
+    output <<- c(output, paragraphs[nzchar(paragraphs)])
+    buffer <<- ""
+  }
+  for (child in node) {
+    child_tag <- tag(child)
+    if (child_tag %in% c("\\itemize", "\\enumerate", "\\describe")) {
+      flush()
+      items <- list_items(child)
+      marker <- if (child_tag == "\\enumerate") paste0(seq_along(items), ". ") else rep("- ", length(items))
+      output <- c(output, paste(paste0(marker, items), collapse = "\n"))
+    } else if (child_tag %in% c("\\preformatted", "\\deqn")) {
+      flush()
+      output <- c(output, paste0("```text\n", trim_code(plain_text(child)), "\n```"))
+    } else if (child_tag == "\\item") {
+      # \item{name}{description} entries of \value.
+      flush()
+      output <- c(output, paste0("- ", code_span(plain_text(child[[1L]])), ": ", squash(inline(child[[2L]], TRUE))))
+    } else {
+      buffer <- paste0(buffer, inline(child))
+    }
+  }
+  flush()
+  paste(output, collapse = "\n\n")
+}
+
+trim_code <- function(text) {
+  lines <- strsplit(text, "\n", fixed = TRUE)[[1L]]
+  lines <- sub("[ \t]+$", "", lines)
+  while (length(lines) > 0L && !nzchar(lines[[1L]])) lines <- lines[-1L]
+  while (length(lines) > 0L && !nzchar(lines[[length(lines)]])) lines <- lines[-length(lines)]
+  indents <- nchar(lines[nzchar(lines)]) - nchar(sub("^ +", "", lines[nzchar(lines)]))
+  if (length(indents) > 0L && min(indents) > 0L) {
+    lines <- substring(lines, min(indents) + 1L)
+  }
+  paste(lines, collapse = "\n")
+}
+
+table_cell <- function(text) {
+  gsub("\n", " ", text, fixed = TRUE)
+}
+
+# One function -----------------------------------------------------------------
+
+render_function <- function(name, rd) {
+  title <- squash(inline(section(rd, "\\title"), TRUE))
+  description_text <- blocks(section(rd, "\\description"))
+  lines <- c(
+    paste0("(", anchor(name), ")="),
+    paste0("### `", name, "()`"),
+    ""
+  )
+  if (!identical(squash(gsub("[.]$", "", title)), squash(gsub("[.]$", "", description_text)))) {
+    lines <- c(lines, paste0("*", title, "*"), "")
+  }
+  lines <- c(lines, description_text, "")
+
+  usage <- section(rd, "\\usage")
+  if (!is.null(usage)) {
+    lines <- c(lines, "**Usage**", "", "```r", trim_code(plain_text(usage)), "```", "")
+  }
+
+  arguments <- section(rd, "\\arguments")
+  if (!is.null(arguments)) {
+    items <- Filter(function(child) identical(tag(child), "\\item"), arguments)
+    rows <- vapply(items, function(item) {
+      argument_names <- trimws(strsplit(plain_text(item[[1L]]), ",", fixed = TRUE)[[1L]])
+      paste0(
+        "| ", paste(vapply(argument_names, code_span, character(1)), collapse = ", "),
+        " | ", table_cell(squash(inline(item[[2L]], TRUE))), " |"
+      )
+    }, character(1))
+    lines <- c(lines, "**Arguments**", "", "| Argument | Description |", "| --- | --- |", rows, "")
+  }
+
+  value <- section(rd, "\\value")
+  if (!is.null(value)) {
+    lines <- c(lines, "**Value**", "", blocks(value), "")
+  }
+
+  details <- section(rd, "\\details")
+  if (!is.null(details)) {
+    lines <- c(lines, "**Details**", "", blocks(details), "")
+  }
+
+  for (custom in sections(rd, "\\section")) {
+    lines <- c(lines, paste0("**", squash(inline(custom[[1L]], TRUE)), "**"), "", blocks(custom[[2L]]), "")
+  }
+
+  examples <- section(rd, "\\examples")
+  if (!is.null(examples)) {
+    lines <- c(lines, "**Examples**", "", "```r", trim_code(plain_text(examples)), "```", "")
+  }
+
+  see_also <- section(rd, "\\seealso")
+  if (!is.null(see_also)) {
+    lines <- c(lines, "**See also**", "", blocks(see_also), "")
+  }
+  lines
+}
+
+# Page -----------------------------------------------------------------------
+
+rd_files <- sort(list.files(file.path(package_path, "man"), pattern = "[.]Rd$", full.names = TRUE))
+pages <- lapply(rd_files, tools::parse_Rd, permissive = FALSE)
+documented <- list()
+for (rd in pages) {
+  doc_type <- section(rd, "\\docType")
+  if (!is.null(doc_type) && identical(trimws(plain_text(doc_type)), "package")) {
+    next
+  }
+  aliases <- vapply(sections(rd, "\\alias"), function(node) trimws(plain_text(node)), character(1))
+  for (alias in intersect(aliases, exports)) {
+    documented[[alias]] <- rd
+  }
+}
+
+grouped <- unlist(lapply(GROUPS, `[[`, "functions"))
+missing_docs <- setdiff(grouped, names(documented))
+if (length(missing_docs) > 0L) {
+  stop("No help page for: ", paste(missing_docs, collapse = ", "), call. = FALSE)
+}
+ungrouped <- setdiff(names(documented), grouped)
+if (length(ungrouped) > 0L) {
+  warning(
+    "Exported functions without a group are listed under 'Other functions': ",
+    paste(ungrouped, collapse = ", "),
+    call. = FALSE
+  )
+  GROUPS[[length(GROUPS) + 1L]] <- list(
+    title = "Other functions",
+    label = "r-api-other",
+    intro = "Other exported functions of CyteTypeR.",
+    functions = sort(ungrouped)
+  )
+}
+
+summary_rows <- unlist(lapply(GROUPS, function(group) {
+  vapply(group$functions, function(name) {
+    title <- squash(inline(section(documented[[name]], "\\title"), TRUE))
+    paste0("| [`", name, "()`](#", anchor(name), ") | ", group$title, " | ", table_cell(title), " |")
+  }, character(1))
+}))
+
+page <- c(
+  "---",
+  DESCRIPTION_LINE,
+  "---",
+  "",
+  "(r-api)=",
+  "# R function reference",
+  "",
+  paste0(
+    "This page is generated from the help pages of CyteTypeR ", package_version,
+    ". To regenerate it, run `Rscript docs/render_r_reference.R ../CyteTypeR` from",
+    " the root of the CyteType repository. In R, the same help is available with",
+    " `?` and the function name, for example `?PrepareCyteTypeR`."
+  ),
+  "",
+  "| Function | Section | Purpose |",
+  "| --- | --- | --- |",
+  summary_rows,
+  ""
+)
+for (group in GROUPS) {
+  page <- c(
+    page,
+    paste0("(", group$label, ")="),
+    paste0("## ", group$title),
+    "",
+    group$intro,
+    ""
+  )
+  for (name in group$functions) {
+    page <- c(page, render_function(name, documented[[name]]))
+  }
+}
+
+page <- sub("[ \t]+$", "", page)
+text <- paste(page, collapse = "\n")
+text <- gsub("\n{3,}", "\n\n", text)
+if (grepl("
+  options:
+    folders: true
+    logo_text: CyteType
+    hide_authors: true
diff --git a/docs/source/platform/chat.md b/docs/source/platform/chat.md
new file mode 100644
index 0000000..eb9cad2
--- /dev/null
+++ b/docs/source/platform/chat.md
@@ -0,0 +1,115 @@
+---
+description: Ask questions about one or more completed CyteType runs in Chat, which answers from the stored results and your uploaded data with text, tables and figures you can download.
+---
+
+(chat)=
+# Chat
+
+Chat, at `https://cytetype.nygen.io/chat`, is a workspace for asking questions about completed runs. It reads the annotation results of the runs you attach and, when the job uploaded them, the cell metadata and the expression matrix. It answers in text and saves tables, figures and other outputs that you can reuse and download.
+
+You need to sign in with your CyteType account to use Chat. Having a report link is not enough, even for a public report.
+
+## Open Chat
+
+- **From a report**: click **Open Chat** in the report toolbar. Chat opens in a new tab with the run attached and the cluster you were viewing selected. If you are not signed in, the button reads **Sign in required**.
+- **From the dashboard**: click **Chat** on a completed report, or **Chat** in the sidebar for an empty conversation.
+- **Directly**: go to `https://cytetype.nygen.io/chat`.
+
+When you open Chat for a report you have discussed before, it asks whether to continue your most recent conversation about that report or start a new chat.
+
+## Attach runs
+
+Chat calls attached runs "sources". A conversation can have up to 10.
+
+1. Click the paperclip (**Add source**) next to the message box.
+2. Under **Find a completed report**, type a report name or ID and click **Find**. Click **Find** with an empty box to list the most recent reports you can attach.
+3. Click **Attach** next to the report.
+
+You can attach completed runs that you can open: your own, those shared with you, those visible to your organization and public reports (see [Visibility and sharing](sharing.md)). Runs that are still processing cannot be attached. Archived reports are not listed in the search, but you can attach one by entering its full report ID.
+
+Each attached run appears as a chip under the message box. Click **Γ—** on a chip to detach the run. After you detach a run, Chat no longer uses it or the earlier answers that were based on it, although those answers stay visible to you.
+
+## Select clusters
+
+To point Chat at particular clusters, click **Clusters** on a source chip, search by cluster ID or label, and click **Target** next to each cluster you want. You can select up to 12 clusters per message, across all attached runs. Selected clusters appear as chips under the message box; click **Γ—** to remove one.
+
+Chat focuses on the selected clusters: for example, plots show only those clusters unless you ask for all of them. Opening Chat from a report selects the cluster you were viewing.
+
+## Ask questions
+
+Type your question and press **Send**. While Chat works, a status line shows its progress, and the answer appears as it is written. Click the stop button to cancel an answer.
+
+Chat looks up your data with tools before it makes claims about a dataset, and it answers concisely, saving large tables as outputs rather than writing them into the answer. What it can do depends on what the job uploaded:
+
+| Data | Available for |
+|---|---|
+| Annotation results: labels, L1 to L5 levels, Cell Ontology terms, cell states, reviewer confidence, heterogeneity, evidence, candidates, reviews, pathways | Every run |
+| Percentage of cells in each cluster that express a gene | Every run |
+| Queries and summaries of cell metadata (donors, samples, conditions) | Runs that uploaded the cell metadata (`obs.duckdb`) |
+| Per-cell expression summaries and plots, cell-level differential expression | Runs that uploaded the expression matrix (`vars.h5`) and the cell metadata |
+| Ligand-receptor interactions between clusters | Human and mouse runs with at least two clusters that uploaded both files |
+
+See [Prepare your data](../guides/prepare-data.md) for how the clients build and upload these files.
+
+### Questions that work well
+
+- Evidence: "Why was cluster 7 labeled as plasmacytoid dendritic cells? What evidence argued against it, and which other candidates were considered?"
+- Review: "Which clusters have Low reviewer confidence or are flagged as heterogeneous? Summarize the reviewers' concerns for each."
+- Expression: "Make a dot plot of CD3E, MS4A1, LYZ, NKG7 and PPBP across all clusters."
+- Metadata: "How are donors distributed across clusters? Show it as a stacked bar chart of proportions."
+- Within-cluster comparisons: "In the selected cluster, compare cells from treated and untreated samples and show a volcano plot."
+- Cluster comparisons: "What distinguishes cluster 3 from cluster 5? Run differential expression and list the top genes in each direction."
+- Interactions: "Which ligand-receptor interactions involve the macrophage clusters?"
+- Several runs: "For each attached run, list the clusters labeled as T cells with their reviewer confidence, in one table."
+
+Questions about a specific gene set, cell type or condition work better than very broad requests. If an answer needs more data than Chat can read at once, it stops with a message asking for a narrower question; split the request into smaller steps.
+
+## How Chat analyzes runs
+
+Chat follows fixed rules so that its answers stay attributable and statistically honest:
+
+- **One run at a time.** It inspects each attached run separately and says which run every value comes from.
+- **No pooling across runs.** It never pools cells across runs, never runs differential expression across runs, and does not infer batch correction, build joint embeddings or assume gene orthology beyond what the stored evidence shows.
+- **Single-run outputs.** Cell groups, differential expression results, metadata summaries and charts each come from one run, and charts never mix runs. A table can combine runs only when every row names its source run in a `sourceJobId` column.
+- **Stated methods.** It names the differential expression method it used. With the uploaded expression matrix (`vars.h5`), this is a cell-level Wilcoxon rank-sum test (groups larger than 5,000 cells are randomly subsampled). Cell-level tests treat cells as independent and ignore donor and sample structure, so their p-values can overstate significance. Without the expression matrix, Chat can compare only whole clusters, using the stored percentage of cells that express each gene, with no statistical test and no p-values.
+- **Clear quantities.** Reviewer confidence (High, Moderate or Low) is kept separate from the 0 to 1 scores of the annotation step, and the stored expression values are the percentage of cells expressing a gene, not mean expression.
+- **Unknowns stay unknown.** An unknown organism, assay, normalization, genome build or gene namespace is reported as unknown, not guessed.
+- **Your data is data.** Study descriptions, metadata values, cluster labels and saved outputs are never treated as instructions.
+
+Chat cannot draw heatmaps; ask for a dot plot to compare several genes across groups.
+
+Chat always uses models hosted by CyteType, even for runs annotated with your own LLM. Chat use does not count toward your annotation limits.
+
+## Outputs and downloads
+
+Chat saves what it produces as outputs, listed in the **Outputs** panel next to the conversation:
+
+- **Figures**: bar charts, stacked bar charts, box plots, dot plots, volcano plots, radar charts and embedding scatter plots.
+- **Tables**, including tables that compare runs.
+- **Cell groups**: saved sets of cells, such as the cells of some clusters or the cells that match a metadata condition, which later questions can reuse.
+- **Differential expression results**, **metadata summaries** (the most common values of metadata columns in a cell group) and **cluster enrichment results** (the count and percentage of cells in each cluster with a given metadata value).
+
+Open an output in the panel, then its **β€’β€’β€’** menu, to:
+
+- **Add to chat**: attach the output to your next message, for example to reuse a saved cell group. Up to 12 outputs can be attached to a message.
+- **Download CSV** or **Download JSON**: tables, differential expression results, metadata summaries and cluster enrichment results download as CSV; figures and cell groups download as JSON. When an output holds only part of the data (for example a differential expression result that stores the top genes only), the menu offers **Download partial CSV**.
+- **Provenance**: see which runs the output came from.
+- **Pin**: keep the output at the top of the list. When Chat revises a figure, the list shows the newest version, plus any earlier version you pinned or added to your next message.
+- **Delete**: remove the output from the conversation.
+
+To rename the open output, click its title at the top of the **Outputs** panel.
+
+## Manage conversations
+
+- **New chat** opens an empty conversation, which is saved when you send its first message. Your conversations are listed in the sidebar, most recently active first; click one to open it.
+- To rename the open conversation, click its title at the top of the page.
+- Open the **β€’β€’β€’** menu next to that title and click **Delete** to delete the conversation with its messages and outputs.
+- Chat runs one answer at a time for your account. If an answer is running in another conversation, Chat offers to open it or stop it before you send a new message.
+
+Conversations are private to your account: people who can open the same runs do not see them. If you lose access to a run, for example because it was deleted or unshared, the answers and outputs that came from it are hidden.
+
+## Limits
+
+A conversation can have up to 10 attached runs and 100 messages, and your account can keep up to 100 conversations. An answer can create up to 12 outputs, and a table holds up to 500 rows. Chat reads the last 20 completed exchanges of a conversation, and it may drop the oldest ones, or older tool results within an answer, to stay within its reading limit.
+
+[Usage limits and quotas](limits.md#limits-chat) lists every Chat limit, including the message length and the time an answer may take, and the message you see when you reach each one.
diff --git a/docs/source/platform/dashboard.md b/docs/source/platform/dashboard.md
new file mode 100644
index 0000000..f4fcf71
--- /dev/null
+++ b/docs/source/platform/dashboard.md
@@ -0,0 +1,221 @@
+---
+description: Manage your CyteType reports, API keys and account in the dashboard, in your personal workspace or your organization's workspace.
+---
+
+(dashboard)=
+# Dashboard
+
+The dashboard at `https://cytetype.nygen.io/dashboard` is where you manage your reports, your API keys and your account. It needs a signed-in browser session: if you are not signed in, it sends you to the sign-in page first and brings you back afterwards. See [Sign in and API keys](../getting-started/sign-in.md).
+
+## Open the dashboard
+
+Open the URL in a browser, or open it from your terminal or R session:
+
+::::{tab-set}
+:::{tab-item} Python
+:sync: python
+```bash
+cytetype dashboard
+```
+:::
+:::{tab-item} R
+:sync: r
+```r
+OpenCyteTypeDashboard()
+```
+:::
+::::
+
+Both open the dashboard of the server you signed in to, or `https://cytetype.nygen.io/dashboard` when there is no saved sign-in. They also print the URL, so you can copy it when no browser opens.
+
+The sidebar links to **Chat**, **Reports**, **API keys** and the documentation. At the bottom are the workspace switcher (only for members of an organization with an active license) and your account.
+
+## Workspaces
+
+Every account has a personal workspace. If your account belongs to an organization with an active license, the sidebar also shows a workspace switcher with your organization (and your role in it) and **Personal**. The dashboard offers one organization per account: the one it joined first. The Reports, API keys and Account pages always show the selected workspace only.
+
+| | Personal workspace | Organization workspace |
+|---|---|---|
+| Who has it | Every account | Members of an organization with an active license |
+| Reports | Jobs submitted with your personal API keys | Jobs submitted with your organization's API keys |
+| API keys | Personal keys (`cyt_p_...`) that you create | Organization keys (`cyt_...`) that admins create |
+| Usage counted against | Your personal rolling limits | The organization's cluster quota |
+| Visibility options | Private, Public | Private, Organization, Public |
+
+The API key that submits a job decides its workspace. A job submitted with a personal key belongs to your personal workspace and counts toward your personal limits. A job submitted with an organization key belongs to the organization workspace and counts toward the organization's quota. Browser sign-in with `cytetype setup` or `SetupCyteTypeR()` gives you a personal key, so to run jobs under your organization, use an organization key from an admin (see [Sign in and API keys](../getting-started/sign-in.md)).
+
+When an organization's license expires, its workspace is no longer offered in the switcher.
+
+## Reports
+
+The **Reports** page has two tabs. The first is the same in both workspaces:
+
+- **My Reports**: the reports you own in this workspace, whether you submitted them or they were transferred to you.
+
+The second depends on the workspace:
+
+- **Shared with Me** (personal workspace): personal reports that other people shared with you by email.
+- **Workspace Reports** (organization workspace): other members' reports with Organization or Public visibility, and their private reports that were shared with you.
+
+Each row in **My Reports** shows the report name, the report ID (with a copy button), the status (Processing, Completed or Failed), the visibility, who it is shared with, the number of clusters, the creation date and the actions. The other tab shows the owner instead of the sharing details.
+
+:::{note}
+A report that belongs to an organization you are not a member of does not appear in either tab, even when it was shared with you. Open it from the link in the share email.
+:::
+
+### Open a report, Chat or target analysis
+
+Each row has up to three buttons:
+
+- **View** opens the report in a new tab (see [The CyteType report](../results/report.md)).
+- **Chat** opens [Chat](chat.md) in a new tab with this run attached. It appears for completed reports only.
+- **Target analysis** opens the [target analysis](target-analysis.md) dialog. It appears for completed reports only.
+
+### Rename a report
+
+In **My Reports**, click the report name, type the new name and press Enter (Esc cancels). Reports without a name show "Untitled". You can also name a report when you submit it, with a `title` key in `metadata` (see [Study context and metadata](../guides/study-context.md)). Only the owner can rename a report.
+
+### Change the visibility
+
+Click the edit icon next to the visibility badge, choose **Private**, **Organization** or **Public**, and click **Save**. **Organization** is offered only in the organization workspace. You can change the visibility in either direction at any time. [Visibility and sharing](sharing.md) explains what each level allows.
+
+### Share by email
+
+1. Tick the reports to share and click **Share**, or click the **Shared With** cell of a single report.
+2. Enter an email address and click **Add** (or press Enter). Repeat for more people.
+3. Click **Save Changes**.
+
+What happens next:
+
+- Each recipient gets view access and one email that links to every report newly shared with them in this save.
+- A recipient without a CyteType account is listed as **Pending**. The invite turns into access the first time they sign in with that address.
+- Sharing again with someone who already has access does not send another email.
+- Recipients need an institutional email address, the same rule as for signing in.
+- With several reports selected, the dialog only adds people. To remove someone, including a pending invite, open the dialog for that one report, remove the address and click **Save Changes**.
+
+Only the owner can share a report. Owners can also share from the **Share** button on the report page.
+
+### Transfer ownership
+
+Transfer is available in the organization workspace only.
+
+1. Tick the reports and click **Transfer**.
+2. Enter the new owner's email address and click **Transfer All**.
+3. Confirm.
+
+The new owner must be a member of the same organization. A transfer cannot be undone: the new owner gets all owner rights (rename, visibility, sharing, deletion) and you lose them. If the report is private, you also lose access to it unless the new owner shares it with you or sets it to Organization.
+
+### Archive a report
+
+Tick the reports and click **Archive**. Archived reports disappear from the list until you tick **Show archived**; select them there and click **Unarchive** to bring them back.
+
+Archiving does not change who can open the report: the link keeps working for everyone who could open it before. It removes the report from your lists, and from the report search in Chat and the job list of the [MCP connector](mcp.md) for everyone. An archived report can still be attached in Chat or connected through MCP by its full report ID.
+
+### Delete a report
+
+Tick the reports in **My Reports**, click **Delete** and confirm. Only the owner can delete a report.
+
+Deletion is a soft delete:
+
+- It cannot be undone from the dashboard. Deleted reports stay in **My Reports** under **Show deleted**, without any actions.
+- Everyone loses access, including you. All shares and pending invites are removed, and anyone who opens the link sees "Report Deleted: This report has been deleted by the owner."
+- Requests for its results with an API key fail with the error code `REPORT_DELETED`.
+- Chat and the MCP connector can no longer use it. Chat answers and outputs that were based on it become unavailable.
+- Deleted jobs still count toward your usage limits or your organization's quota (see [Usage limits and quotas](limits.md)).
+
+If you delete a report that is still processing, it disappears from your views but the job keeps running on the server until it finishes.
+
+## API keys
+
+API keys let the Python and R clients submit jobs and fetch results. The **API keys** page lists the keys of the selected workspace.
+
+### Create a key
+
+1. Select the workspace the key is for.
+2. Click **Create API key**.
+3. Enter a **Key name**, for example the computer or pipeline that will use it.
+4. Optionally enter a **Quota limit**: the maximum number of clusters this key can annotate in total. Leave it empty for no per-key limit. Your other limits still apply.
+5. Organization admins can enter a member's address in **User email (optional)** to create the key for that member.
+6. Click **Create API key**, then copy the key from the **API key created** panel.
+
+:::{warning}
+The dashboard shows a key you create here only once. If you lose it, disable it and create a new one.
+:::
+
+To use the key, save it with `cytetype login` or `LoginCyteTypeR()`, or pass it as `auth_token`. [Remote servers, SSH and headless machines](../getting-started/sign-in.md#sign-in-remote) walks through it.
+
+In the organization workspace, only admins can create keys. A member who clicks **Create API key** sees a dialog listing the organization's admins to contact.
+
+### The key list
+
+| Column | Meaning |
+|---|---|
+| Owner | The member who owns the key (shown to organization admins) |
+| Name | The key name |
+| Key ID | The key's prefix and last four characters. It identifies the key but cannot be used as a key. |
+| Status | Active or Disabled |
+| Jobs, Clusters | Running and completed jobs submitted with the key, and their clusters, including deleted jobs |
+| Quota | The key's cluster limit, or Unlimited |
+| Created | The creation date |
+
+The **Active** filter shows active keys only; **All** also shows disabled keys. The **Sort** menu orders keys by date created, name, clusters used, jobs run or quota.
+
+### Rename a key or change its quota
+
+Click the edit icon next to the name or the quota, change the values and click **Save Changes**. Leave the quota empty for no per-key limit. In your personal workspace you can edit your own keys. In the organization workspace, only admins can edit keys, and they can edit any organization key.
+
+### Disable a key
+
+Click **Disable** and confirm. The key stops working at once: uploads and job submissions that use it are rejected with the error code `TOKEN_INACTIVE` (an `AuthenticationError` in Python), and it can no longer fetch the results of private or organization reports. Jobs that are already running still finish; fetch their results with another valid key.
+
+Disabling is permanent. Keys cannot be re-enabled, and they cannot be deleted: disabled keys stay in the list under **All** as a record. Members can disable their own organization keys; admins can disable any organization key.
+
+### The "CyteType CLI" key
+
+Browser sign-in (`cytetype setup` or `SetupCyteTypeR()`) does not always create a new key. It hands out your active personal key named "CyteType CLI", including its secret, and creates that key only when you have no active one. So:
+
+- every computer you sign in from this way shares one key, and each browser sign-in returns its secret again;
+- disabling the "CyteType CLI" key here signs all of those computers out, and the next browser sign-in creates a new one ([Switch accounts or servers](../getting-started/sign-in.md#sign-in-switch) explains how to sign in again after a key was rejected).
+
+For a computer or pipeline that should have a key of its own, create one on this page instead ([Remote servers, SSH and headless machines](../getting-started/sign-in.md#sign-in-remote)).
+
+`cytetype logout` and `LogoutCyteTypeR()` only delete the saved copy on that computer. To stop a key from working anywhere, disable it here.
+
+## Account
+
+Open the Account page from your email address at the bottom of the sidebar, or at `https://cytetype.nygen.io/account`. It shows the selected workspace:
+
+- **Profile**: the email address of your account.
+- **Workspace**: the workspace name, your role, the license type and, for an organization, the license end date ("No end date" for licenses without one).
+- **Usage and limits**:
+  - Personal workspace: the jobs remaining in the last 24 hours, 7 days and 30 days, with how many of each you have used, and the total clusters and datasets you have annotated.
+  - Organization workspace: the clusters annotated in the current license period, the cluster limit, the datasets processed and the clusters remaining.
+- **Appearance**: System, Light or Dark.
+- **Support and resources**: **Contact support** (an email to contact@nygen.io), the documentation on GitHub and, in the personal workspace, a link to book a meeting when one is available.
+- **Session**: **Sign out** of this browser.
+
+[Usage limits and quotas](limits.md) explains what counts toward each number.
+
+## Organization roles
+
+Organization members are either admins or members. Admin rights cover API keys; they give no extra access to reports.
+
+| Action | Member | Admin |
+|---|---|---|
+| Manage their own reports (rename, visibility, share, transfer, archive, delete) | Yes | Yes |
+| Open other members' Organization and Public reports | Yes | Yes |
+| Open other members' private reports | Only if shared with them | Only if shared with them |
+| Create organization API keys | No | Yes, for themselves or any active member |
+| See organization API keys | Their own | All, with owner and usage |
+| Rename keys and change quotas | No | Any organization key |
+| Disable keys | Their own | Any organization key |
+
+Admins cannot see members' personal workspaces (personal reports and personal keys) or their Chat conversations, which are private to each user.
+
+The dashboard has no controls for organization membership or roles. A new account whose email domain belongs to an organization with an active license joins it as a member at its first sign-in. To change admins or license settings, contact contact@nygen.io.
+
+## See also
+
+- [Visibility and sharing](sharing.md): what each visibility level and an email share allow.
+- [Usage limits and quotas](limits.md): what counts toward the numbers on the Account page.
+- [Sign in and API keys](../getting-started/sign-in.md): save a key from the API keys page on another computer.
diff --git a/docs/source/platform/limits.md b/docs/source/platform/limits.md
new file mode 100644
index 0000000..53a39bb
--- /dev/null
+++ b/docs/source/platform/limits.md
@@ -0,0 +1,182 @@
+---
+description: CyteType usage limits for personal accounts and organizations, the data, job, Chat and MCP limits, the errors you get when you reach them, and how to get more capacity.
+---
+
+(limits)=
+# Usage limits and quotas
+
+CyteType is free for academic and non-commercial research within the limits on this page. Personal accounts have rolling job limits; organizations have a cluster quota set by their license. A few technical limits apply to everyone. The [Account page](dashboard.md) shows how much you have left.
+
+## Personal accounts
+
+Jobs submitted with a personal API key (`cyt_p_...`) count toward three rolling windows:
+
+| Window | Jobs |
+|---|---|
+| Last 24 hours | 10 |
+| Last 7 days | 50 |
+| Last 30 days | 100 |
+
+The windows roll: a job stops counting 24 hours, 7 days or 30 days after you submitted it. When you reach a limit, the error tells you which window is full and roughly when it frees up.
+
+What counts:
+
+- Jobs that are running or completed, including jobs you later archive or delete.
+- Jobs annotated with your own LLM. Passing `llm_configs` does not bypass the limits (see [Use your own LLM](../guides/llm-configuration.md)).
+
+What does not count:
+
+- Failed jobs.
+- Requests rejected at submission, for example by an invalid key or a failed model check.
+- Jobs submitted with an organization key, which count toward the organization's quota instead.
+- Viewing reports, fetching results, Chat, the MCP connector and target analysis.
+
+## Organizations
+
+Jobs submitted with an organization API key (`cyt_...`) count toward the organization's cluster quota:
+
+- The quota is a number of clusters per license period, set by the license. For a license without an end date, all of the organization's jobs count.
+- The clusters of running and completed jobs count, including jobs that were later deleted. Failed jobs do not count.
+- A job is accepted only if all of its clusters fit in the remaining quota.
+- When the license expires, uploads and new jobs with the organization's keys are refused.
+
+The Account page in the organization workspace shows the clusters annotated in the current period and the clusters remaining.
+
+## Per-key quotas
+
+Any API key, personal or organization, can have its own **Quota limit**: the maximum number of clusters that jobs submitted with the key can contain, over the key's lifetime. Running and completed jobs count, including deleted ones; failed jobs do not. A key quota applies in addition to your personal limits or your organization's quota.
+
+You set quotas on your personal keys yourself. Organization admins set them on organization keys, for example to give each member or pipeline a share of the organization's quota. See [Dashboard](dashboard.md).
+
+## When limits are checked
+
+The clients upload your data files before they submit the job:
+
+1. When an upload starts, the server checks that your API key is valid and active and that its organization's license has not expired.
+2. When the job is submitted, after the upload, the server checks your personal limits, the key's quota, the organization's quota and the requested visibility.
+
+A job refused at step 2 does not run, and its upload is lost: you upload again when you resubmit. Before submitting a large dataset, check the Account page. Organization visibility needs a key with an organization license; when you request it with a key that looks like a personal key (`cyt_p_...`), both clients warn before the upload.
+
+## Data and upload limits
+
+| File | Maximum size |
+|---|---|
+| `obs.duckdb` (cell metadata) | 2 GiB (2,147,483,648 bytes) |
+| `vars.h5` (expression matrix) | 50 GiB (53,687,091,200 bytes) |
+
+Both clients check the file sizes before uploading. A file that is too large stops the run with an error that names the file ("exceeds upload limit"); in Python it is a `ValueError`. With `require_artifacts=False`, the job continues without that file (CyteTypeR without either file), and features that need it (cell-level queries in Chat and MCP, cell-level differential expression, target analysis) are unavailable for the job. [Large datasets](../guides/large-datasets.md) explains how to reduce the file sizes.
+
+An uploaded file expires if no job uses it within 24 hours. The clients submit the job right after uploading and upload again on every run, so you do not need to act on this.
+
+## Job limits
+
+| Limit | Value |
+|---|---|
+| Marker genes used per cluster | 250 (any more are ignored) |
+| `n_parallel_clusters` | 1 to 50; used only with your own LLM |
+| Server time limit per job | 4 hours; the job is then stopped and reported as failed |
+| Client wait (`timeout_seconds`) | 7,200 seconds (2 hours) by default |
+| Target analysis run | 3 hours |
+
+When the client stops waiting, Python raises `CyteTypeTimeoutError` and CyteTypeR stops with an error of class `cytetype_timeout_error` that names the job. The job keeps running on the server. Fetch the results later with `get_results()` in Python or `GetResults(job_id = ...)` in R (see [Run, monitor and rerun jobs](../guides/running-jobs.md)).
+
+(limits-chat)=
+## Chat limits
+
+| Limit | Value |
+|---|---|
+| Runs attached to a conversation | 10 |
+| Clusters selected per message | 12 |
+| Outputs attached to a message | 12 |
+| Message length | 16,000 characters |
+| Answers running at the same time | 1 per account, across all conversations |
+| Time per answer | 10 minutes waiting to start, 30 minutes running |
+| Outputs created per answer | 12 |
+| Rows per table | 500 |
+| Conversations | 100 per account |
+| Messages per conversation | 100, including stopped and failed ones |
+
+When you reach one of these limits, Chat tells you what to do:
+
+| Situation | Message |
+|---|---|
+| Another answer is running | "Another Chat turn is still running. Open that conversation or wait for it to finish." |
+| 100 conversations | "You have reached the saved-conversation limit. Delete an older conversation before starting another." |
+| 100 messages in a conversation | "This conversation has reached its turn limit. Start a new conversation to continue." |
+| An answer waited or ran too long | "The turn exceeded its execution window. Please retry.", "The chat turn exceeded its execution deadline." or "The Chat turn exceeded its time limit." |
+| An answer gathered too much data | "This turn gathered more tool output than the model can read at once. Ask a narrower question or split it into smaller steps." |
+
+See [Chat](chat.md).
+
+(limits-mcp)=
+## MCP connector limits
+
+| Limit | Value |
+|---|---|
+| Connected jobs | 10 per signed-in user and AI client |
+| Connected jobs forgotten after | 12 hours without a tool call that uses them |
+| Cell-level differential expression | 20 runs per hour per user |
+| Label comparison (`compare_labels_cyteonto`) | 10 requests per hour per user |
+| Rows per metadata query | 200 |
+| Genes per expression lookup | 30 (`lookup_gene_expression`), 10 (`summarize_gene_expression`) |
+| Groups per expression summary | The 40 largest groups, with up to 5,000 sampled cells per group |
+| Cells per group in cell-level differential expression | 5,000 (larger groups are subsampled) |
+| Genes per direction in differential expression | 1 to 100 |
+| Embedding points | 2,000 by default, at most 5,000 |
+| Target candidates per ranking | 10 by default, at most 25 |
+| Sign-in | Renewed automatically while in use; sign in again after 6 days without use |
+
+When an hourly limit is reached, the tool returns a message such as "Rate limit reached: at most 20 differential expression runs on cell-level data per hour. Try again in about 12 minute(s)." See [Connect AI assistants (MCP)](mcp.md).
+
+## Errors when you reach a limit
+
+| Situation | Error code | Python exception | Message |
+|---|---|---|---|
+| Personal limit reached | `RATE_LIMIT_EXCEEDED` | `RateLimitError` | "Daily limit reached (10/10). Resets in about 3 hours. For a free academic license with higher limits, email contact@nygen.io" ("Weekly" and "Monthly" for the other windows) |
+| Organization quota too small for the job | `QUOTA_EXCEEDED` | `QuotaExceededError` | "Quota exceeded: This job requires 24 clusters but you only have 10 remaining (990/1000 used). Please contact your organization admin" |
+| Key quota too small for the job | `QUOTA_EXCEEDED` | `QuotaExceededError` | The same message, without the last sentence |
+| Organization license expired | `LICENSE_EXPIRED` | `AuthorizationError` | "License has expired. Please contact your organization admin(s): ..." |
+| Key disabled | `TOKEN_INACTIVE` | `AuthenticationError` | "API token has expired or been deactivated" |
+| Organization visibility with a key without an organization license, such as a personal key | `HTTP_ERROR` | `APIError` | "Org visibility requires an organization license" |
+| Upload file too large | (checked by the client) | `ValueError` | "... exceeds upload limit: ..." |
+| Job stopped after 4 hours | (job status) | `JobFailedError` | "Job ... failed" |
+| Client stopped waiting | (checked by the client) | `CyteTypeTimeoutError` | "Job ... did not complete within ...s" |
+
+The numbers in the messages are examples. The CyteType exceptions have `.error_code` and `.message` attributes and can be imported from `cytetype`:
+
+::::{tab-set}
+:::{tab-item} Python
+:sync: python
+```python
+from cytetype import QuotaExceededError, RateLimitError
+
+try:
+    adata = annotator.run(study_context="Human PBMC from a healthy donor")
+except RateLimitError as err:
+    print(err.error_code)  # RATE_LIMIT_EXCEEDED
+    print(err.message)     # which window is full and when it frees up
+except QuotaExceededError as err:
+    print(err.message)     # clusters required and clusters remaining
+```
+:::
+:::{tab-item} R
+:sync: r
+```r
+obj <- tryCatch(
+  CyteTypeR(obj, prepped_data, study_context = "Human PBMC from a healthy donor"),
+  error = function(e) {
+    message(conditionMessage(e))  # the server's message
+    obj
+  }
+)
+```
+:::
+::::
+
+In R, a refused submission stops with a message that starts with "Job submission failed:" and includes the error code in square brackets, for example `[QUOTA_EXCEEDED]`. A personal-limit error shows the server's message directly, followed by a reminder that the limits apply to every job, including jobs that use your own LLM. [Errors and error codes](../reference/errors.md) lists every error.
+
+## Get more capacity
+
+- **Academic and non-commercial research**: email contact@nygen.io about a free academic license with higher limits.
+- **Commercial use** requires a license. Email contact@nygen.io.
+- **Organizations**: admins can see the organization's usage on the Account page and rebalance per-key quotas in the dashboard. To raise the quota or renew the license, contact contact@nygen.io.
diff --git a/docs/source/platform/mcp.md b/docs/source/platform/mcp.md
new file mode 100644
index 0000000..796edfd
--- /dev/null
+++ b/docs/source/platform/mcp.md
@@ -0,0 +1,224 @@
+---
+description: Connect Claude, Claude Code, Cursor or another MCP client to CyteType to explore and compare your annotation jobs from your own AI assistant.
+---
+
+(mcp)=
+# Connect AI assistants (MCP)
+
+CyteType runs a [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server. Add it to an AI assistant that supports MCP, such as Claude, Claude Code or Cursor, and the assistant can open your CyteType jobs, read their annotations and the evidence behind them, query their data and compare jobs with each other. All tools are read-only: they never change your jobs.
+
+**Server URL:** `https://mcp.cytetype.nygen.io`
+
+The main use is synthesis across jobs. Connect several reports and ask the assistant which findings agree, which differ, and which differences are supported by marker genes, metadata, expression, differential expression or cell-cell interactions. Typical uses:
+
+- comparing disease and control cohorts;
+- checking whether an annotation reproduces across repeated runs;
+- comparing similar tissues, donors, batches or time points;
+- finding shared and study-specific cell populations.
+
+[Chat](chat.md) is the alternative inside CyteType: it uses CyteType-hosted models and saves figures and tables. The MCP connector brings the same data to the assistant you already use.
+
+## Before you start
+
+- A CyteType account. You sign in with your institutional email address, as on the website (see [Sign in and API keys](../getting-started/sign-in.md)).
+- An MCP client that supports remote servers over HTTP with OAuth sign-in.
+
+You do not need an API key: the connector uses its own sign-in.
+
+## Set up your client
+
+### Claude (web, desktop and mobile)
+
+1. In Claude, open **Customize**, then **Connectors** (`https://claude.ai/customize/connectors`).
+2. Click **Add custom connector**.
+3. Enter the name `CyteType` and the server URL `https://mcp.cytetype.nygen.io`, and continue through the dialog. If it asks how to register the OAuth client, choose **Register automatically**: the CyteType server supports automatic (dynamic) registration.
+4. Click **Add**, then sign in (see [Sign in](#mcp-sign-in)). If the connector shows **Connect** instead, click it to sign in.
+5. In a conversation, click **+** next to the message box, choose **Connectors** and turn CyteType on.
+
+On Team and Enterprise plans, an organization owner first adds the connector under **Organization settings**, **Connectors**. Each member then finds it under **Customize**, **Connectors**, clicks **Connect** and signs in with their own CyteType account. A connector added in Claude is available in the web app, Claude Desktop and the mobile apps.
+
+### Claude Code
+
+Add the server from your terminal:
+
+```bash
+claude mcp add --transport http --scope user cytetype https://mcp.cytetype.nygen.io
+```
+
+`--scope user` makes the connector available in all your projects; without it, it is added to the current project only. Then start Claude Code, run `/mcp`, select `cytetype` and choose **Authenticate**. Your browser opens the CyteType sign-in page.
+
+`claude mcp list` shows whether the server is connected or still needs authentication. `claude mcp remove cytetype` removes it, together with its stored sign-in.
+
+### Cursor
+
+Add the server to `~/.cursor/mcp.json` (all projects) or to `.cursor/mcp.json` in a project folder:
+
+```json
+{
+  "mcpServers": {
+    "cytetype": {
+      "url": "https://mcp.cytetype.nygen.io"
+    }
+  }
+}
+```
+
+Cursor supports OAuth sign-in for remote servers: when it connects to the server, it sends you to the CyteType sign-in page. You can enable or disable the server in Cursor's MCP settings.
+
+### Other clients
+
+Any MCP client can connect if it supports:
+
+- remote servers over Streamable HTTP;
+- OAuth 2.0 sign-in with dynamic client registration and PKCE;
+- a sign-in redirect to a local address on your computer (`http://localhost` or `http://127.0.0.1`, any port). Redirects to Claude and Cursor are also accepted; a client that registers any other redirect address is refused.
+
+A client that only runs local (stdio) servers can connect through the `mcp-remote` bridge, which needs Node.js and handles the sign-in through your browser. In the client's MCP configuration:
+
+```json
+{
+  "mcpServers": {
+    "cytetype": {
+      "command": "npx",
+      "args": ["mcp-remote", "https://mcp.cytetype.nygen.io"]
+    }
+  }
+}
+```
+
+(mcp-sign-in)=
+## Sign in
+
+The first time your client connects, it opens the CyteType sign-in page ("Sign in to connect"):
+
+1. Enter your email address and click **Continue**.
+2. Enter the 8-digit code from the email. It is checked as soon as you have entered all eight digits, or when you click **Verify**.
+3. The page confirms and sends you back to your client.
+
+Signing in follows the website's rules: you need an institutional email address, a first sign-in creates your account, and reports already shared with that address become available.
+
+Finish signing in within 30 minutes; after that, start the connection again from your client. Your client then stays signed in while you use it: access renews automatically and expires after 6 days without use, after which you sign in again. To stop a client from using your account, remove or disconnect the connector in that client. CyteType cannot end a client's sign-in from its side: the access a client already holds lapses on its own, within an hour, and its renewal within 6 days.
+
+You must sign in to use the connector, also for public reports. Once signed in, you can open:
+
+- public reports;
+- your own reports;
+- reports shared with you;
+- reports visible to your organization, while you are a member.
+
+These are the same rules as for the report page and Chat (see [Visibility and sharing](sharing.md)). Unlike Chat, the connector also opens jobs that are still running.
+
+## Connect jobs
+
+Ask the assistant to connect a job by its report ID or report URL (`https://cytetype.nygen.io/report/`). Without an ID, it can list your jobs or, in clients that support it, let you choose one. The response gives the job's status:
+
+| Status | Meaning |
+|---|---|
+| `completed` | The job is finished |
+| `processing`, `pending` | The job is still running; its annotation results appear when it reaches its final step, so treat what you see as partial |
+| `failed` | The job failed |
+| `incomplete` | An older job with missing results |
+
+It also gives the number of clusters and annotated clusters, the start of the study context and a summary per cluster.
+
+- Up to 10 jobs can be connected at once.
+- Connected jobs belong to your account and your client, so every conversation in that client sees the same list. They are forgotten after 12 hours in which no tool call uses them.
+- With one job connected, the assistant does not need to name it. With several, it passes the job ID to each tool.
+- Access is checked when a job is connected and again at least once a minute while it is used. If a job is deleted or your access ends, it is disconnected and the next tool call says why.
+
+## Tools
+
+The tools fall into six groups. Your assistant chooses them from your request; you do not need to name them.
+
+### Session
+
+| Tool | What it does |
+|---|---|
+| `connect_job` | Connects a job by ID or report URL, or lets you choose from your jobs; reports its status and whether results are partial |
+| `list_my_jobs` | Lists up to 100 recent completed jobs you can open: your own, those shared with you and those visible to your organization (not other people's public jobs, and not archived jobs) |
+| `list_connected_jobs` | Lists the connected jobs with status, cluster counts and labels, and any that were disconnected or could not be loaded |
+| `disconnect_job` | Removes a job from the connected jobs; the job itself is not changed |
+
+### Job overview and comparison
+
+| Tool | What it does |
+|---|---|
+| `get_job_overview` | One row per cluster: label, classification, L1 to L5 levels, Cell Ontology term, cell state, reviewer confidence, selection score, heterogeneity, status and your original label |
+| `list_clusters` | Clusters with label, status, top 10 marker genes, reviewer confidence, selection score, heterogeneity, original label and label-comparison score |
+| `get_cluster_detail` | Everything about one cluster, including marker genes and the context narrative |
+| `get_study_context` | The study context used during annotation, with the submitted study description, organism, keywords and header tags |
+| `compare_connected_jobs` | Lines up clusters across connected jobs, with each job's organism, keywords and study summary |
+
+### Expression and metadata
+
+| Tool | What it does |
+|---|---|
+| `lookup_gene_expression` | Percentage of cells in each cluster that express each gene (not mean expression), for up to 30 genes |
+| `summarize_gene_expression` | Per-cell expression statistics for groups of cells defined by metadata columns, for up to 10 genes and 40 groups; needs the uploaded files |
+| `describe_metadata` | The cell metadata columns, their types, value counts and likely roles (cluster, sample, donor, disease, treatment) |
+| `query_metadata` | One read-only SQL `SELECT` over the cell metadata table `obs`; at most 200 rows are returned |
+| `cluster_metadata_enrichment` | Per-cluster count and percentage of cells with a given metadata value |
+| `run_differential_expression` | Differential expression between two non-overlapping groups of clusters, stating the method used |
+| `get_cell_interactions` | Ligand-receptor interactions for the whole dataset or one cluster |
+
+### Annotation evidence
+
+| Tool | What it does |
+|---|---|
+| `get_annotation_evidence` | Supporting, missing and unexpected genes behind a label, with reasoning |
+| `get_annotation_context_fit` | How the label fits the tissue, study and cluster context |
+| `get_cell_identity` | Cell state with its gene sets, the Cell Ontology term and why it was chosen, and the granular cell type |
+| `get_v2_annotation_trace` | How the final label was reached: the L1 to L5 levels, classification, why no deeper level was supported, and the decision record |
+| `get_annotation_candidates` | The candidate cell types considered, with reasoning and reasons for rejection |
+| `get_candidate_gene_groups` | Supporting and refuting gene sets for one candidate |
+| `get_embedding_coordinates` | Subsampled 2D coordinates with cluster centroids: 2,000 points by default, at most 5,000 |
+
+### Quality and review
+
+| Tool | What it does |
+|---|---|
+| `get_quality_control` | Reviewer confidence with reasoning, selection score, heterogeneity, classifier accuracy, marker validation against CellGuide and SingleCellBase, and the predictions of an independent cell-type model compared with the label |
+| `get_expert_reviews` | Specialist reviewer assessments: the evidence examined, strengths, weaknesses, interpretation, alternatives and conclusion |
+| `get_neighbor_degs` | Pre-computed differential genes against the most similar clusters |
+| `get_co_expression_evidence` | Gene pairs tested for co-expression within a cluster's cells, with statistics and rationale |
+| `get_pathway_details` | Pathway enrichment per gene-set library, with normalized enrichment scores |
+
+### Targets and labels
+
+| Tool | What it does |
+|---|---|
+| `get_target_discovery` | The job's latest [target analysis](target-analysis.md) run: status, options, design, populations in scope and the top candidates of each ranking (10 by default, at most 25) |
+| `compare_labels_cyteonto` | Scores the job's original cluster labels against CyteType's labels from 0 to 100 with CyteOnto, and gives the reason for each skipped cluster |
+
+### Reading the results
+
+- `reviewerConfidence` (High, Moderate or Low) comes from the independent review step. `selectionScore` is the annotation step's 0 to 1 score for its final choice and is on a different scale.
+- `classification` is `catalogue` (the label matches an entry in the reference cell-type hierarchy), `outOfCatalog` (a coherent identity without a reference entry) or `unresolved` (no identity could be assigned).
+- A cluster without a result has `annotated: false`. Your own label is kept in `originalLabel` and is never presented as CyteType's label.
+- `compare_connected_jobs` matches clusters by Cell Ontology term first, then by identical L1 to L5 levels, and finally by label text alone. Rows matched by text alone are flagged `textOnlyMatch`; treat them as weak evidence. Clusters without an annotation or with an unresolved identity are listed as excluded.
+- `run_differential_expression` names its method. With the job's uploaded expression data, it runs a cell-level Wilcoxon rank-sum test on up to 5,000 cells per group, with Benjamini-Hochberg adjusted p-values; cells are treated as independent observations, so the p-values can overstate significance. Without uploaded data, it compares the percentage of cells expressing each gene, with no statistical test and no p-values.
+- `query_metadata` accepts one `SELECT` from `obs`, with `WHERE`, `GROUP BY`, `HAVING`, `ORDER BY`, `LIMIT` and window functions; joins, subqueries, `WITH`, multiple statements and file or network functions are rejected.
+
+## Example prompts
+
+- "Connect to job `` and summarize its clusters with their reviewer confidence."
+- "What share of cells express CD3E, MS4A1 and LYZ in each cluster?"
+- "Compare clusters 0 and 3 against clusters 1 and 5 with differential expression, and tell me which method was used."
+- "Show the evidence for cluster 2's label, the other candidates that were considered and how the final label was chosen."
+- "How many cells per condition are in each cluster?"
+- "How does IL7R expression differ between conditions within each cluster?"
+- "Which clusters have Low reviewer confidence, and what did the expert reviewers flag?"
+- "Connect jobs `` and `` and compare their cell populations. Check each job's organism and tissue first, and treat label-only matches as tentative."
+
+## Limits
+
+Besides the limits on connected jobs and query sizes described above, two tools have hourly limits per user: cell-level differential expression with `run_differential_expression` (20 runs per hour) and `compare_labels_cyteonto` (10 requests per hour). When an hourly limit is reached, the tool says when to try again. [Usage limits and quotas](limits.md#limits-mcp) lists every connector limit.
+
+## Privacy
+
+- **Read-only.** The tools cannot change your jobs, their visibility or their sharing. `disconnect_job` only changes the list of connected jobs.
+- **Your access, nothing more.** The assistant can open only the jobs your account can open, and access is checked again at least once a minute while a job is in use.
+- **What the assistant sees.** Everything a tool returns is sent to your AI client and the model provider behind it: annotations, study descriptions and metadata tags and, for jobs with uploaded data, rows of the cell metadata table (up to 200 per query) and expression statistics. How that data is stored and used is governed by your AI provider's terms and settings, not by CyteType. Check that your data-use agreements allow it before you connect jobs with sensitive metadata.
+- **What CyteType sees.** CyteType receives the tool calls your assistant makes, with their arguments (such as job IDs, gene lists and SQL queries), not the rest of your conversation.
+
+See [Security and privacy](../support/security-and-privacy.md).
diff --git a/docs/source/platform/sharing.md b/docs/source/platform/sharing.md
new file mode 100644
index 0000000..f2458b3
--- /dev/null
+++ b/docs/source/platform/sharing.md
@@ -0,0 +1,115 @@
+---
+description: Control who can open a CyteType report with private, organization or public visibility and email sharing, and understand what each level exposes.
+---
+
+(sharing)=
+# Visibility and sharing
+
+Every report has a visibility level, and its owner can also share it with individual people by email. Together they decide who can open the report page, use the run in [Chat](chat.md) and connect it from an AI assistant through the [MCP connector](mcp.md). Fetching results with an API key follows slightly different rules, described below.
+
+## Visibility levels
+
+| Visibility | Who can open the report |
+|---|---|
+| **Private** (default) | You, and the people you share it with |
+| **Organization** | Every member of the report's organization, and the people you share it with |
+| **Public** | Anyone with the link, without signing in |
+
+New reports are private unless you choose otherwise. Organization visibility applies only to reports in an organization workspace, which are the jobs submitted with an organization API key (see [Dashboard](dashboard.md)).
+
+## Choose the visibility when you submit
+
+Pass `visibility` to `run()` (cytetype 0.21.0 or later) or to `CyteTypeR()` (CyteTypeR 0.6.0 or later): `"private"`, `"org"` or `"public"`. Leaving it out (`None` in Python, `NULL` in R) uses the server default, private. The requested value is also saved with the job details, in `adata.uns["cytetype_jobDetails"]["visibility"]` or `obj@misc$cytetype_jobDetails$visibility` (with the default `results_prefix`).
+
+::::{tab-set}
+:::{tab-item} Python
+:sync: python
+```python
+adata = annotator.run(
+    study_context="Human PBMC from a healthy donor",
+    visibility="public",
+)
+```
+:::
+:::{tab-item} R
+:sync: r
+```r
+obj <- CyteTypeR(
+  obj, prepped_data,
+  study_context = "Human PBMC from a healthy donor",
+  visibility = "public"
+)
+```
+:::
+::::
+
+:::{warning}
+Organization visibility needs an organization API key (`cyt_...`). A personal key (`cyt_p_...`) is refused even if your account belongs to an organization. When the key looks like a personal key, both clients warn before they upload your files, but only the server decides, when the job is submitted, after the upload: with a key that has no organization license, the run stops with an `APIError` with the code `HTTP_ERROR` ("Org visibility requires an organization license"; in R, `Job submission failed: [HTTP_ERROR] Org visibility requires an organization license`), no job is created and the upload is discarded. Run it again with an organization key, or submit it as private and change the visibility in the dashboard.
+:::
+
+## Change the visibility later
+
+In the dashboard, open **My Reports**, click the edit icon next to the report's visibility, choose the new level and click **Save**. You can change it in either direction at any time; **Organization** is offered only in the organization workspace.
+
+The change applies at once:
+
+- The report page checks access each time it loads data, so people who no longer qualify lose access straight away.
+- Chat checks access before each answer, and hides earlier answers and outputs that came from runs the person can no longer open.
+- The MCP connector checks access at least once a minute while a job is in use, and disconnects jobs that are no longer accessible.
+
+Changing the visibility cannot recall copies that people already made, such as CSV downloads, Chat downloads or results an AI assistant has already received.
+
+## Share with specific people
+
+The owner can share a report with people by email, from the dashboard or with the **Share** button on the report page. The steps are in [Dashboard](dashboard.md).
+
+- Recipients get view access: they can open the report and use the run in Chat and over MCP. They cannot rename it, change its visibility, share it further, transfer it or delete it.
+- Each recipient gets an email with the link and the address to sign in with.
+- A recipient without a CyteType account is listed as **Pending** until they sign in for the first time with that address; the invite then turns into access.
+- Recipients need an institutional email address, the same rule as for signing in.
+- Shares work at every visibility level, including for people outside your organization.
+- You can remove a share or a pending invite at any time in the dashboard. Deleting a report removes all of its shares and invites.
+
+Recipients find personal reports shared with them under **Shared with Me** in their dashboard, and reports of their own organization under **Workspace Reports**. A report from an organization they do not belong to is not listed in their dashboard; they open it from the link in the email.
+
+## Who can open what
+
+| Access | Private | Organization | Public |
+|---|---|---|---|
+| Report page | The owner and the people it is shared with | Also all members of the organization | Anyone with the link, signed in or not |
+| Chat (sign-in required) | The owner and the people it is shared with | Also all members of the organization | Any signed-in user |
+| MCP connector (sign-in required) | The owner and the people it is shared with | Also all members of the organization | Any signed-in user |
+| Results with an API key (`get_results()`, `GetResults()`) | The owner's API keys | Also organization API keys of the same organization | Any API key |
+
+Points to note:
+
+- Email shares give access to the report page, Chat and MCP, but not to results through an API key. A recipient who calls `get_results()` or `GetResults()` with their own key gets an `ACCESS_DENIED` error (an `AuthorizationError` in Python). To give a collaborator the results, they can download the CSV from the report page, or you can make the report Organization (for members of your organization who use an organization key) or Public.
+- For organization reports, API-key access works only with the organization's keys (`cyt_...`), not with members' personal keys.
+- Chat opens completed runs only. The MCP connector also opens jobs that are still running and marks their results as partial.
+- Organization admins have no extra access to reports (see [Organization roles](dashboard.md)).
+- Nobody can open a deleted report.
+
+## What a public link exposes
+
+A public report publishes more than the page you see. Anyone with the link, without signing in, can:
+
+- view the whole report: labels, Cell Ontology terms, cell states, confidence and heterogeneity calls, every evidence and review section, the study summary and the marker dot plot;
+- see the inputs the report is built from: the study description and metadata tags you submitted (including any links), your own cluster labels if you supplied them, the per-cluster summaries of categorical cell metadata, the marker genes, the 2D embedding coordinates sent with the job (a subsample of cells), and the percentage of cells in each cluster that express each gene, for every gene in the dataset;
+- download the annotation table as CSV.
+
+The job's latest [target analysis](target-analysis.md) run and its results belong to the report's data, so they are public too, even though the report page does not display them.
+
+In addition, anyone with a CyteType account can:
+
+- fetch the results with their own API key (`get_results()`, `GetResults()`);
+- find the report in Chat's report search, which lists public reports alongside the user's own, so a public report can be found by name and not only through its link;
+- attach the run in Chat and analyze the uploaded cell-level data: queries over the cell metadata table, per-cell expression summaries and plots, and differential expression between groups of cells;
+- connect the job through the MCP connector, whose tools include read-only SQL queries over the cell metadata (up to 200 rows per query) and per-cell expression statistics.
+
+The uploaded files cannot be downloaded as files, but Chat and MCP return results computed from them, including individual rows of the cell metadata table. That table holds every cell metadata column you uploaded (`adata.obs` or the Seurat `meta.data`), not only the columns shown in the report.
+
+A public link does not let anyone change the report, its visibility or its sharing, and it does not let them start a target analysis.
+
+:::{important}
+Before making a report public, check that you could publish everything listed above, including every cell metadata column (for example donor or sample identifiers), under your data-use agreements and institutional policies. See [Security and privacy](../support/security-and-privacy.md).
+:::
diff --git a/docs/source/platform/target-analysis.md b/docs/source/platform/target-analysis.md
new file mode 100644
index 0000000..14fc5d4
--- /dev/null
+++ b/docs/source/platform/target-analysis.md
@@ -0,0 +1,219 @@
+---
+description: Rank cell-type-resolved target hypotheses for a completed CyteType job from its uploaded counts and metadata and public target knowledge, and read the results and their limits.
+---
+
+(target-analysis)=
+# Target analysis
+
+Target analysis turns a completed annotation job into cell-type-resolved target hypotheses, each backed by evidence you can audit. It answers two kinds of question:
+
+- **Discovery**: which genes, in which annotated populations, are the strongest candidates across the dataset?
+- **Target assessment**: you name the targets, and it profiles each one in every population in scope: expression, disease association, consistency across donors, specificity and public facts.
+
+The output is a ranked, annotated list of hypotheses for further study. It does not establish that any target causes disease, works or is safe; see [Limits](#target-limits).
+
+:::{note}
+Target analysis is available on licensed plans. Contact contact@nygen.io for access.
+:::
+
+## What CyteType adds
+
+- **Reviewed cell identities.** Population labels are anchored to the Cell Ontology, lineage runs from L1 to L5, and cell state is kept apart from identity. The reviewer's confidence and heterogeneity calls are used to flag weak or mixed populations, because a target claim for a cell type depends on that cell type being called correctly.
+- **Cell-level data the job already holds.** The raw counts and the donor and condition metadata uploaded with the job.
+- **Provenance.** Every number traces to a computation, and every routing and design choice is recorded with who made it: you, a rule, a model or a default.
+
+## Requirements
+
+- **A licensed plan** that includes target analysis.
+- **Permission to start a run.** You own the job, or the job belongs to your organization. Anyone who can open the report can view its results (see [Who can see the results](#target-access)).
+- **A completed job.** Every cluster must have finished annotation.
+- **Uploaded raw counts and cell metadata.** The job needs its cell metadata file (`obs.duckdb`) and an expression file (`vars.h5`) that contains raw integer counts and gene symbols. The clients upload both by default; see [Raw counts](../guides/prepare-data.md#prepare-data-raw-counts) for where they look for the counts. Without them, the run fails with "Target analysis needs uploaded cell metadata (obs.duckdb) and raw counts (vars.h5 with a raw layer and gene symbols)".
+- **For disease claims, a comparison in the metadata.** Disease association needs columns that identify donors (or samples) and the disease and reference conditions, with at least 2 donors in each group; a donor counts for a population only with at least 10 of its cells there. Without such a comparison, or when disease status is confounded with a technical, anatomical or treatment column, or when the groups share no sampling site, the run continues with a descriptive design (see [Claims ladder](#target-claims)).
+
+## Start an analysis
+
+1. Open the [dashboard](dashboard.md) and go to **Reports**.
+2. Find the completed report and click **Target analysis**.
+3. The dialog shows the status of the job's latest run. If you can start a run, it also shows an **Analysis options (JSON)** box, filled with the options of the latest run or with `{}`.
+4. Keep `{}` or edit the options (see [Analysis options](#target-options)), then click **Run analysis**. After a completed run the button reads **Run again**.
+5. Click **Refresh** to update the status.
+
+If you cannot start a run, the dialog says why: your plan does not include target analysis, your organization's license has expired, or you are neither the owner nor a member of the job's organization. You can still view existing results.
+
+A job has one current run at a time:
+
+- You cannot start a run while another one for the same job is queued or running.
+- A new run replaces the previous one as the job's latest run. The dialog and the MCP connector show results only for the latest run, once it completes, so save the results JSON before you start another run.
+- A run that has not reported progress for 20 minutes is treated as stopped, and the dialog lets you start another.
+- A run is stopped after 3 hours.
+
+(target-options)=
+## Analysis options
+
+The options are a JSON object. `{}` lets CyteType infer everything from the study. Each key you add narrows the analysis; unknown keys are rejected.
+
+| Key | Value | Effect |
+|---|---|---|
+| `disease` | Text, for example `"ulcerative colitis"` | Names the indication instead of inferring it from the study description and metadata. It is matched to a disease term in the Open Targets knowledge base; if no term matches, the disease stays unresolved. |
+| `diseaseArea` | `"oncology"`, `"immunology"`, `"neurology"`, `"metabolic"` or `"general"` | Overrides the area inferred from the disease. The area sets the scoring weights, which evidence counts as genetic support, whether a known cancer driver counts as a safety liability (it does not in oncology), and the area-specific interpretation questions. |
+| `targets` | A list of up to 50 objects `{"gene": "...", "direction": "..."}`; `direction` is optional: `"inhibit"`, `"activate"` or `"deplete"` | Switches from discovery to target assessment of these genes. Each gene may appear once. `deplete` means removing the cells that carry the target. |
+| `modalities` | A list from `"smallMolecule"`, `"antibody"`, `"antibodyDrugConjugate"`, `"cellTherapy"`, `"oligonucleotide"`, `"proteinDegrader"`, `"geneTherapy"` | Restricts candidates to targets that these modalities can act on, and adds a question about modality fit. |
+| `cellTypes` | A list of up to 20 cell types | Restricts the populations in scope. Each entry is matched to the CyteType label, granular label, lineage levels or Cell Ontology term of the populations (a Cell Ontology ID such as `"CL:0000236"` also works); without an exact match, a model chooses among the populations. A cell type that matches nothing stops the run with an error rather than widening the scope. |
+| `comparison` | `{"conditionColumn": "...", "diseaseLabels": [...], "referenceLabels": [...], "donorColumn": "..."}`; `donorColumn` is optional | Fixes the condition column, the disease and reference labels and, optionally, the donor column instead of inferring them. The column and labels are cell metadata column names and values; disease and reference labels must not overlap, and a column or label that does not exist stops the run. The other design checks still apply, so the design can still become descriptive. |
+
+Discovery with an explicit disease and cell types:
+
+```json
+{
+  "disease": "ulcerative colitis",
+  "cellTypes": ["fibroblast", "CL:0000236"]
+}
+```
+
+Target assessment for antibody targets:
+
+```json
+{
+  "targets": [
+    {"gene": "TNF", "direction": "inhibit"},
+    {"gene": "IL23A"}
+  ],
+  "modalities": ["antibody"]
+}
+```
+
+An explicit comparison:
+
+```json
+{
+  "disease": "ulcerative colitis",
+  "comparison": {
+    "conditionColumn": "disease",
+    "diseaseLabels": ["ulcerative colitis"],
+    "referenceLabels": ["normal"],
+    "donorColumn": "donor_id"
+  }
+}
+```
+
+When you leave `disease` out and the study profiles more than one disease in separate patient groups, the run stops and asks you to name one: "The study profiles several diseases: ... Name the one to analyse in the request's disease field; each needs its own run."
+
+## How a run works
+
+The status line shows the current stage:
+
+| Stage | What it does |
+|---|---|
+| load | Reads the job's annotated populations and uploaded files |
+| route | Decides the objective, disease, disease area, modalities and populations in scope |
+| design | Plans the disease-versus-reference comparison from the metadata, or explains why there is none |
+| measurements | Computes expression, specificity, disease association and changes in population abundance from the raw counts |
+| knowledge | Collects public facts from Open Targets for every gene that can become a candidate |
+| scoring | Scores and ranks candidates under two lenses and checks how stable the rankings are |
+| interpretation | Asks a model short questions about the top candidates |
+
+Only the route, design and interpretation stages use a language model. In the route and design stages, the model's choices are checked against the options offered. Every number comes from computation, and model interpretations never change scores.
+
+## Results
+
+When a run is complete, click **Open results JSON** in the dialog. It opens the run record and its result as JSON in a new tab, where you can save it. The report page does not display target analysis results.
+
+The result holds:
+
+| Key | Content |
+|---|---|
+| `request` | The options the run used |
+| `route` | The objective, disease, disease areas, populations in scope and the routing decisions, each with its source |
+| `design` | `comparative` or `descriptive`; the donor and condition columns, disease and reference labels, donors per group, covariates, compared sites, checks and decisions |
+| `populations` | Per population: cell counts, the disease association and abundance-shift summaries, the top 5 candidates under each lens, and any conflict between the annotation and where the population is found |
+| `rankings` | One ranking per lens (`novelty` and `validation`), with the criterion weights and the top 25 candidates in discovery, or every candidate in target assessment |
+| `interpretations` | The model's answers about the interpreted candidates (at most 30) |
+| `genes` | Public facts for the ranked genes |
+| `knowledge` | The Open Targets release used and what could not be resolved |
+| `exclusions`, `unmeasuredTargets`, `limitations` | How many candidates were excluded and why, requested targets that could not be measured, and the limitations computed for this run |
+| `versions`, `stageSeconds` | Versions of the result schema, playbooks, thresholds and scoring, and the time each stage took |
+
+### Which genes become candidates
+
+In discovery, a gene becomes a candidate in a population when at least 5% of the population's cells, and at least 5 cells, express it (in a comparative design, of the compared disease and reference cells). Non-protein-coding genes, gene symbols shared by more than one gene in the data, genes that none of the requested modalities can act on, and genes characteristic of another cell type (most likely doublets or contamination) are dropped; `exclusions` counts them. In target assessment, every requested gene is kept in every population in scope, and these conditions become flags instead.
+
+### Read a ranked candidate
+
+Each candidate is a gene in a population (in discovery, each gene appears once, at its best population, with the other populations that score nearly as well listed). It has:
+
+- **score**: the weighted mean of its criteria under the lens.
+- **criteria**: the criteria in the table below, each scaled from 0 to 1, with the measured value and the value used in the score. A criterion that cannot be measured for any candidate in the run, such as disease association in a descriptive design, is left out and the other weights are rescaled.
+- **coverage**: the share of the applicable weight backed by measurements. Unknown values take a fixed prior, the base rate for a typical gene (zero for most evidence criteria), and a criterion that cannot apply (such as disease association for a population found only in disease) is left out.
+- **topShare** and **rankInterval**: how stable the rank is when the criterion weights are redrawn 200 times. `topShare` is how often the candidate stays in the top list; `rankInterval` is its 5th to 95th percentile rank.
+- **direction**: the intervention (inhibit, activate or deplete) you requested, or else the one the data imply, if any.
+- **evidence**: the fraction of cells expressing, counts per million, the donor fraction, the specificity ratio and the runner-up population, the disease-versus-reference log2 fold change (raw and shrunk) with adjusted p-values, donor consistency and donor counts, the abundance shift, and, when the signal may come from another population, which one.
+- **flags**: cautions (see the second table below). A flag does not change the score by itself, but the conditions behind three flags already weaken the disease association: it is halved for `exploratoryDonorCount` and treated as unknown for `ambientLikeShift` and `otherCellTypeGene`.
+
+| Criterion | Meaning |
+|---|---|
+| Disease association | Donor-level expression change between disease and reference within the population |
+| Population shift | Donor-level change in the population's abundance with disease |
+| Population specificity | Expression in the population relative to other cell types in the dataset, scaled by the share of donors who express it |
+| Tissue specificity | Body-wide tissue specificity from public expression atlases |
+| Genetic support | Human genetic evidence linking the gene to the disease |
+| Tractability | Evidence that a requested (or any feasible) modality can act on the target |
+| Safety | Absence of known on-target safety liabilities |
+| Clinical precedent | The most advanced clinical program against the target in any indication |
+| Indication activity | Clinical programs against the target in this disease or its subtypes |
+
+The two lenses weight the criteria differently. **novelty** favors strong data signals on targets with little clinical precedent or indication activity, and in most disease areas gives population specificity more weight; **validation** favors strong data signals on targets with clinical precedent. Each ranking in the result lists its weights.
+
+| Flag | Meaning |
+|---|---|
+| `notExpressed` | Expressed by fewer than 5% of the population's cells, or by fewer than 5 cells |
+| `ambiguousSymbol` | The gene symbol is shared by more than one gene in the uploaded data |
+| `notProteinCoding` | Not a protein-coding gene |
+| `notInKnowledgeBase` | No single matching entry in the knowledge base |
+| `modalityIncompatible` | None of the requested modalities can act on it |
+| `directionConflict` | The requested direction is the opposite of the one the data imply |
+| `exploratoryDonorCount` | The comparison has fewer than 3 donors in a group |
+| `lowAnnotationConfidence` | The population's reviewer confidence is Low |
+| `heterogeneousPopulation` | The population was flagged as heterogeneous |
+| `ambientLikeShift` | The disease shift may come from another population's RNA (ambient RNA or doublets) |
+| `otherCellTypeGene` | The gene is characteristic of another cell type and most likely comes from doublets or contamination |
+| `annotationContextConflict` | The population's annotation conflicts with where it is found, for example a non-malignant cell type found only in disease |
+
+(target-claims)=
+## Claims ladder
+
+What a result can support depends on the data the job holds:
+
+| Data available | Claims allowed | Claims not allowed |
+|---|---|---|
+| Annotation only, no uploaded files | None: the run fails | |
+| Raw counts and cell metadata | Expression level, fraction of cells expressing, specificity in the profiled tissue | Disease association; specificity outside the tissue |
+| Donors with disease and reference labels, at least 3 donors per group | Donor-level disease difference within a population, donor consistency, change in abundance | Causality; benefit of an intervention |
+
+Comparisons with 2 donors per group run, but count as exploratory: their disease-association score is halved and they get the `exploratoryDonorCount` flag. Small cohorts detect only large changes, so a gene without a significant change may still differ.
+
+(target-limits)=
+## Limits
+
+Target analysis never claims:
+
+1. **Causality.** An expression change can follow from disease rather than cause it.
+2. **Efficacy or benefit** of an intervention.
+3. **Safety clearance.** RNA from one tissue cannot clear safety.
+4. **Protein presence, surface accessibility or activity.** Surface location comes from public annotations, never from the RNA data.
+5. **Novelty, freedom to operate, patent position, licensing or market size.** The novelty lens reflects only how little public clinical activity a target has.
+6. **Generalization beyond the cohort** without replication.
+7. **Absence from missing evidence.** An untested gene is not a null result.
+
+No output approves a target for development. Also keep in mind:
+
+- Public facts describe human genes and are matched by gene symbol. For a non-human study, such as mouse, orthology is assumed rather than checked, a gene whose human ortholog has a different symbol gets no public facts, and the result lists this limitation.
+- Abundance changes are relative: one population expanding makes the others shrink.
+- The criterion weights are research defaults, not fitted values. Use `topShare` and `rankInterval` to see how much a ranking depends on them.
+- Interpretations are model-generated text based on the computed evidence and the model's general knowledge. Treat them as prompts for follow-up, not as findings.
+
+(target-access)=
+## Who can see the results
+
+Target analysis results belong to the report's data, so everyone who can open the report can read them: the people you share it with, members of your organization for an Organization report, and anyone with the link for a public report (see [Visibility and sharing](sharing.md)). In the dashboard, the **Target analysis** button of a personal report shared with someone shows them the results without letting them start a run. Members of the job's organization can start runs on its reports if their plan includes target analysis.
+
+AI assistants connected through the [MCP connector](mcp.md) can read the latest run with the `get_target_discovery` tool: its status and options, the disease and comparison design, the populations in scope with their disease-associated gene counts and abundance shifts, the top candidates of each lens (10 by default, at most 25) with their scores, coverage, direction, flags and key evidence, and the run's limitations. While the latest run is queued, running or failed, the tool reports that status instead of results. The connector cannot start a run.
diff --git a/docs/source/reference/api/conf.py b/docs/source/reference/api/conf.py
new file mode 100644
index 0000000..94b25c8
--- /dev/null
+++ b/docs/source/reference/api/conf.py
@@ -0,0 +1,64 @@
+import sys
+from pathlib import Path
+
+SOURCE_ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(SOURCE_ROOT.parents[1]))
+
+import cytetype  # noqa: E402
+
+# cytetype exports its TimeoutError as CyteTypeTimeoutError, so that star imports do not
+# hide the builtin. Autodoc documents a class under another name as a bare alias, so give
+# the class its exported name for this build. Its canonical name stays
+# cytetype.api.exceptions.TimeoutError, which docstrings reference.
+cytetype.CyteTypeTimeoutError.__name__ = "CyteTypeTimeoutError"
+
+project = "CyteType API"
+copyright = "2025-2026, Nygen Analytics AB"
+author = "Nygen Analytics AB"
+extensions = [
+    "myst_parser",
+    "sphinx.ext.autodoc",
+    "sphinx.ext.napoleon",
+    "sphinx_autodoc_typehints",
+    "sphinx_copybutton",
+]
+master_doc = "index"
+exclude_patterns = ["_build", "**.ipynb_checkpoints"]
+myst_enable_extensions = ["colon_fence"]
+myst_all_links_external = True
+pygments_style = "sphinx"
+language = "en"
+# Docstrings mark code with single backticks, as in Markdown.
+default_role = "literal"
+
+# Show each class's __init__ arguments with its description.
+autoclass_content = "both"
+autodoc_member_order = "bysource"
+autodoc_inherit_docstrings = False
+# Render "str, optional" in Google-style argument types as text, not as references.
+napoleon_preprocess_types = True
+napoleon_use_rtype = False
+typehints_defaults = "comma"
+# Write unions as "str | None", as the code does, instead of Optional[str].
+always_use_bars_union = True
+
+html_theme = "sphinx_book_theme"
+html_title = "CyteType API reference"
+html_baseurl = "https://docs.nygen.io/CyteType/api/"
+html_theme_options = {
+    "repository_url": "https://github.com/NygenAnalytics/CyteType",
+    "path_to_docs": "docs/source/reference/api",
+    "use_repository_button": True,
+    "show_navbar_depth": 2,
+    "navigation_with_keys": False,
+}
+
+# The reference names these types without linking to their documentation.
+nitpick_ignore = [
+    ("py:class", "anndata.AnnData"),
+    ("py:class", "anndata._core.anndata.AnnData"),
+    ("py:class", "pandas.DataFrame"),
+    ("py:data", "typing.Any"),
+    ("py:data", "typing.Literal"),
+    ("py:exc", "pydantic.ValidationError"),
+]
diff --git a/docs/source/reference/api/cytetype.md b/docs/source/reference/api/cytetype.md
new file mode 100644
index 0000000..653a5fa
--- /dev/null
+++ b/docs/source/reference/api/cytetype.md
@@ -0,0 +1,35 @@
+---
+myst:
+  html_meta:
+    description: Reference for the CyteType class, which prepares an AnnData object, submits an annotation job and stores the results in the AnnData object.
+---
+
+# CyteType class
+
+`CyteType` annotates the clusters of one AnnData object. Creating it checks the inputs,
+summarizes each cluster and writes the upload files `vars.h5` and `obs.duckdb`. `run()`
+uploads the files, submits the job, waits for it to finish and adds the results to the
+AnnData object. `get_results()` returns the stored results or fetches those of a finished
+job, and `cleanup()` deletes the upload files.
+
+```python
+from cytetype import CyteType
+
+annotator = CyteType(adata, group_key="leiden", rank_key="rank_genes_leiden")
+adata = annotator.run(
+    study_context="Peripheral blood mononuclear cells from a healthy human donor."
+)
+results = annotator.get_results()
+annotator.cleanup()
+```
+
+Always use the AnnData object that `run()` returns. The
+[Python quickstart](../getting-started/quickstart) runs these steps on a public dataset,
+[Prepare your data](../guides/prepare-data) describes the input requirements, and
+[Results in AnnData and Seurat](../results/results-in-your-data) describes every column
+and key that `run()` adds.
+
+```{eval-rst}
+.. autoclass:: cytetype.CyteType
+   :members: run, get_results, cleanup
+```
diff --git a/docs/source/reference/api/exceptions.md b/docs/source/reference/api/exceptions.md
new file mode 100644
index 0000000..03e4012
--- /dev/null
+++ b/docs/source/reference/api/exceptions.md
@@ -0,0 +1,78 @@
+---
+myst:
+  html_meta:
+    description: Reference for the exceptions that the CyteType Python client raises, from CyteTypeError to the server errors such as QuotaExceededError.
+---
+
+# Exceptions
+
+The exceptions that CyteType defines all derive from `CyteTypeError`. Errors that the
+server reports derive from `APIError`: the class tells you what went wrong, and
+`error_code` holds the server's error code. `NetworkError` means that the server could not
+be reached or answered unexpectedly, and `CyteTypeTimeoutError` that a request or the wait
+for a job took too long. Import them from `cytetype`:
+
+```python
+from cytetype import CyteTypeError, QuotaExceededError
+
+try:
+    adata = annotator.run(study_context="Human PBMCs from a healthy donor.")
+except QuotaExceededError as error:
+    print(error.error_code, error.message)
+```
+
+`CyteTypeTimeoutError` is the client's own `TimeoutError` class from
+`cytetype.api.exceptions`. The package exports it under this name so that
+`from cytetype import *` does not hide Python's built-in `TimeoutError`.
+`cytetype.api` exports the class as `CyteTypeTimeoutError` and, as in earlier releases,
+as `TimeoutError`, so `from cytetype.api import *` does hide the built-in class.
+[Errors and error codes](../reference/errors) explains what to do about each error.
+
+```{eval-rst}
+.. autoexception:: cytetype.CyteTypeError
+   :show-inheritance:
+
+   .. py:attribute:: message
+      :type: str
+
+      The error message.
+
+   .. py:attribute:: error_code
+      :type: str | None
+
+      The machine-readable error code, such as ``QUOTA_EXCEEDED``, or ``None`` if the
+      error has none.
+
+.. autoexception:: cytetype.APIError
+   :show-inheritance:
+
+.. autoexception:: cytetype.AuthenticationError
+   :show-inheritance:
+
+.. autoexception:: cytetype.AuthorizationError
+   :show-inheritance:
+
+.. autoexception:: cytetype.RateLimitError
+   :show-inheritance:
+
+.. autoexception:: cytetype.QuotaExceededError
+   :show-inheritance:
+
+.. autoexception:: cytetype.JobNotFoundError
+   :show-inheritance:
+
+.. autoexception:: cytetype.JobFailedError
+   :show-inheritance:
+
+.. autoexception:: cytetype.LLMValidationError
+   :show-inheritance:
+
+.. autoexception:: cytetype.ClientDisconnectedError
+   :show-inheritance:
+
+.. autoexception:: cytetype.NetworkError
+   :show-inheritance:
+
+.. autoexception:: cytetype.CyteTypeTimeoutError
+   :show-inheritance:
+```
diff --git a/docs/source/reference/api/helpers.md b/docs/source/reference/api/helpers.md
new file mode 100644
index 0000000..40c7c70
--- /dev/null
+++ b/docs/source/reference/api/helpers.md
@@ -0,0 +1,48 @@
+---
+myst:
+  html_meta:
+    description: Reference for the CyteType helper functions marker_dotplot, rank_genes_groups_backed and subsample_by_group.
+---
+
+# Helpers
+
+The `cytetype` package also exports three helper functions.
+
+## Plot the supporting marker genes
+
+`marker_dotplot` reads the results that `CyteType.run()` stored in the AnnData object and
+draws a Scanpy dot plot of the genes that support each label, with the clusters grouped by
+the categories in the results. It needs Scanpy, which is not a dependency of `cytetype`.
+
+```python
+from cytetype import marker_dotplot
+
+marker_dotplot(adata, group_key="leiden")
+```
+
+```{eval-rst}
+.. autofunction:: cytetype.marker_dotplot
+```
+
+## Find marker genes in backed AnnData
+
+`rank_genes_groups_backed` computes marker genes for an AnnData object opened in backed
+mode, such as with `anndata.read_h5ad(path, backed="r")`. It reads the cells in chunks of
+`cell_batch_size` instead of loading the whole matrix, compares each group with all other
+cells using Welch's t-test, and writes Scanpy-compatible results to `adata.uns[key_added]`.
+Pass the same key to `CyteType` as `rank_key`. [Large datasets](../guides/large-datasets)
+shows where it fits in a workflow.
+
+```{eval-rst}
+.. autofunction:: cytetype.rank_genes_groups_backed
+```
+
+## Subsample cells per cluster
+
+`subsample_by_group` keeps at most `max_cells_per_group` randomly chosen cells from each
+group and returns them as a new in-memory AnnData object. Groups with fewer cells are kept
+whole, and `random_state` makes the selection reproducible.
+
+```{eval-rst}
+.. autofunction:: cytetype.subsample_by_group
+```
diff --git a/docs/source/reference/api/index.md b/docs/source/reference/api/index.md
new file mode 100644
index 0000000..44bed75
--- /dev/null
+++ b/docs/source/reference/api/index.md
@@ -0,0 +1,21 @@
+---
+myst:
+  html_meta:
+    description: Reference for the CyteType Python API, generated from the docstrings of the cytetype package.
+---
+
+# CyteType Python API reference
+
+This reference lists the public Python API of the `cytetype` package: the `CyteType`
+class, the plotting and preprocessing helpers, the fields of an LLM configuration and the
+exceptions. Return to the [CyteType documentation](../) for the guides and the Python
+quickstart.
+
+```{toctree}
+:maxdepth: 1
+
+cytetype
+helpers
+llm-config
+exceptions
+```
diff --git a/docs/source/reference/api/llm-config.md b/docs/source/reference/api/llm-config.md
new file mode 100644
index 0000000..17e5055
--- /dev/null
+++ b/docs/source/reference/api/llm-config.md
@@ -0,0 +1,104 @@
+---
+myst:
+  html_meta:
+    description: Reference for the fields of an LLM configuration, the dicts that CyteType.run() accepts in llm_configs to use your own model provider.
+---
+
+# LLM configuration
+
+`CyteType.run()` accepts your own models in `llm_configs`, a list of dicts. Each dict is
+validated against `cytetype.api.LLMModelConfig` before any file is uploaded: a missing
+required field raises a `pydantic.ValidationError`, and keys that are not fields of the
+model are ignored, so check their spelling (field names are case-sensitive). The server
+then checks every model before the job starts, and `run()` raises `LLMValidationError` if
+one fails. [Use your own LLM](../guides/llm-configuration) explains when to use these fields,
+with examples.
+
+```python
+adata = annotator.run(
+    study_context="Human PBMCs from a healthy donor.",
+    llm_configs=[
+        {"provider": "openai", "name": "", "apiKey": ""},
+    ],
+)
+```
+
+Each configuration needs either `apiKey` or all three AWS fields (`awsAccessKeyId`,
+`awsSecretAccessKey` and `awsDefaultRegion`).
+
+```{eval-rst}
+.. py:class:: cytetype.api.LLMModelConfig
+
+   The fields of one entry in ``llm_configs``.
+
+   .. py:attribute:: provider
+      :type: str
+
+      Required. The model provider: ``anthropic``, ``bedrock``, ``deepseek``,
+      ``fireworks``, ``google``, ``groq``, ``huggingface``, ``mistral``, ``nebius``,
+      ``openai``, ``openrouter``, ``together``, ``vertex`` or ``xai``.
+
+   .. py:attribute:: name
+      :type: str
+
+      Required. The model name, as the provider names it.
+
+   .. py:attribute:: apiKey
+      :type: str | None
+      :value: None
+
+      Your API key for the provider. Required unless the three AWS fields are set.
+
+   .. py:attribute:: baseUrl
+      :type: str | None
+      :value: None
+
+      The base URL of the provider's API. The ``openai`` provider uses it to reach
+      OpenAI-compatible servers, and ``vertex`` uses it too; other providers ignore it.
+
+   .. py:attribute:: awsAccessKeyId
+      :type: str | None
+      :value: None
+
+      AWS access key ID for ``bedrock``. Set all three AWS fields together.
+
+   .. py:attribute:: awsSecretAccessKey
+      :type: str | None
+      :value: None
+
+      AWS secret access key for ``bedrock``.
+
+   .. py:attribute:: awsDefaultRegion
+      :type: str | None
+      :value: None
+
+      AWS region for ``bedrock``.
+
+   .. py:attribute:: modelSettings
+      :type: dict[str, Any] | None
+      :value: None
+
+      Extra settings for the requests to the model, such as ``temperature``.
+
+   .. py:attribute:: targetAgents
+      :type: list[str] | None
+      :value: []
+
+      The CyteType agents that use this model: ``contextualizer``, ``annotator`` or
+      ``reviewer``. An empty list means all agents. ``summarizer``, ``clinician`` and
+      ``chat`` are also accepted but have no effect: the current workflow does not use
+      the summarizer, and target analysis and Chat always use CyteType's hosted models.
+
+   .. py:attribute:: skipValidation
+      :type: bool
+      :value: False
+
+      Has no effect: the server validates every model regardless.
+
+   .. py:attribute:: allowFallback
+      :type: bool
+      :value: False
+
+      If true, an agent tries this model only after its models without
+      ``allowFallback``.
+```
diff --git a/docs/source/reference/cli.md b/docs/source/reference/cli.md
new file mode 100644
index 0000000..12ca706
--- /dev/null
+++ b/docs/source/reference/cli.md
@@ -0,0 +1,308 @@
+---
+description: Reference for the cytetype (Python) and cytetyper (R) command-line tools, with every command, option, default, environment variable, output and exit code, and the matching R functions.
+---
+
+(cli)=
+# Command-line reference
+
+CyteType has two small command-line tools. Both sign you in and open CyteType pages in your browser:
+
+- `cytetype` is installed with the Python package.
+- `cytetyper` is an optional launcher for the R package. Every `cytetyper` command is also an R function, so you can do the same things from an R session without installing it.
+
+Neither tool runs annotations. You submit jobs from Python with `CyteType.run()` and from R with `CyteTypeR()` (see the [Python quickstart](../getting-started/quickstart.ipynb) and the [R quickstart](../getting-started/quickstart-r.md)). For a step-by-step guide to signing in, see [Sign in and API keys](../getting-started/sign-in.md).
+
+(cli-commands)=
+## Commands at a glance
+
+| Task | Python CLI | R function | R CLI |
+|---|---|---|---|
+| Sign in with the browser and save a personal API key | `cytetype setup [--api-url URL] [--force]` | `SetupCyteTypeR(api_url = NULL, force = FALSE)` | `cytetyper setup [--api-url URL] [--force]` |
+| Same as `setup` | `cytetype get-key [--api-url URL] [--force]` | Not available | `cytetyper get-key [--api-url URL] [--force]` |
+| Save an API key you already have | `cytetype login [--api-url URL]` | `LoginCyteTypeR(api_token = NULL, api_url = NULL)` | `cytetyper login [--api-url URL]` |
+| Delete the saved key from this computer | `cytetype logout` | `LogoutCyteTypeR()` | `cytetyper logout` |
+| Open the dashboard | `cytetype dashboard` | `OpenCyteTypeDashboard()` | `cytetyper dashboard` |
+| Open the report of a job | `cytetype view JOB_ID` | `ViewCyteTypeJob(job_id, api_url = NULL)` | `cytetyper view JOB_ID [--api-url URL]` |
+| Print the installed version | `cytetype --version` | `packageVersion("CyteTypeR")` | `cytetyper --version` |
+| Show help | `cytetype --help`, `cytetype COMMAND --help` | `?SetupCyteTypeR` and the other help pages | `cytetyper --help`, `cytetyper COMMAND --help` |
+| Install the R CLI | Not applicable | `InstallCyteTypeRCli(destination = NULL, overwrite = FALSE)` | Not applicable |
+
+All of these share one saved sign-in (see [Saved credentials](#cli-credentials)). Signing in with either tool, or from either package, signs in both clients.
+
+The same commands in the two clients:
+
+::::{tab-set}
+:::{tab-item} Python
+:sync: python
+```bash
+cytetype setup            # sign in with the browser
+cytetype view     # open a report
+cytetype logout           # delete the saved key on this computer
+```
+:::
+:::{tab-item} R
+:sync: r
+```r
+library(CyteTypeR)
+SetupCyteTypeR()             # sign in with the browser
+ViewCyteTypeJob("")  # open a report
+LogoutCyteTypeR()            # delete the saved key on this computer
+```
+
+With the optional launcher, `cytetyper setup`, `cytetyper view ` and `cytetyper logout` do the same from a terminal.
+:::
+::::
+
+(cli-cytetype)=
+## `cytetype` (Python)
+
+The `cytetype` command is installed with the Python package. If your shell cannot find it, activate the environment where `cytetype` is installed, or run the same commands through Python:
+
+```bash
+python -m cytetype.cli setup
+```
+
+Usage:
+
+```text
+cytetype [-h] [--version] {setup,get-key,login,dashboard,view,logout} ...
+```
+
+Running `cytetype` without a command prints the help. Options go after the command name: `cytetype setup --force`, not `cytetype --force setup`.
+
+(cli-setup)=
+### `cytetype setup`
+
+```text
+cytetype setup [--api-url URL] [--force]
+```
+
+Signs you in with your browser and saves a personal API key on this computer.
+
+| Option | Default | Description |
+|---|---|---|
+| `--api-url URL` | `CYTETYPE_API_URL` if set, else `https://cytetype.nygen.io` | The CyteType server to sign in to. Must be a server origin (see [Server URL](#cli-server-url)). |
+| `--force` | Off | Skip the check of the saved key and start a new browser sign-in. Use it when the server no longer accepts the saved key, for example after you disabled it in the dashboard. |
+
+What the command does:
+
+1. If a key for this server is already saved and you did not pass `--force`, it checks the key with the server. If the key is valid, it prints `CyteType is already configured for .` and your dashboard link, and opens no browser. If the server rejects the key as invalid or disabled, the command stops with the server's message followed by "Run `cytetype setup --force` to re-authenticate with a new key".
+2. Otherwise it starts a temporary listener on `127.0.0.1`, on a free port, and opens the CyteType sign-in page in your default browser. It also prints the same link, so you can copy it if no browser opens.
+3. In the browser, sign in with the one-time code sent to your email (if that browser is not signed in yet), then select **Authorize CLI**.
+4. The browser returns to the listener. The command exchanges a short-lived code for your API key, saves it and prints `API key saved for  in .` and your dashboard link. The browser shows "CyteType setup is complete." and moves on to the dashboard after a few seconds.
+
+The command waits at most 5 minutes for you to finish in the browser. The API key never appears in the browser's address bar or in the terminal.
+
+The key you get is your personal key named "CyteType CLI". If that key exists and is active, setup returns the same key, so every computer you sign in to this way shares it. If you disabled it, the next `setup --force` creates a new one.
+
+Example output (the banner is left out):
+
+```text
+Opening CyteType sign-in in your browser.
+If it does not open automatically, use this URL:
+
+
+
+API key saved for you@university.edu in /home/you/.config/cytetype/credentials.json.
+Dashboard:  https://cytetype.nygen.io/dashboard
+```
+
+The browser is opened with `open` on macOS, `xdg-open` on Linux and the Windows default browser under WSL. If none is available, the command prints `Was not able to launch web browser` and keeps waiting: copy the printed link into a browser on the same computer.
+
+### `cytetype get-key`
+
+Another name for `cytetype setup`, with the same options and behavior.
+
+(cli-login)=
+### `cytetype login`
+
+```text
+cytetype login [--api-url URL]
+```
+
+Saves an API key that you already have, for example one created on the **API keys** page of the [dashboard](../platform/dashboard.md). The command asks for the key at a hidden `API key:` prompt, checks it with the server, and saves it only if the server accepts it. If the check fails, nothing is saved and any key saved earlier stays in place. Personal keys (starting with `cyt_p_`) and organization keys (starting with `cyt_`) both work.
+
+| Option | Default | Description |
+|---|---|---|
+| `--api-url URL` | `CYTETYPE_API_URL` if set, else `https://cytetype.nygen.io` | The server that issued the key. |
+
+Output on success:
+
+```text
+Signed in as you@university.edu.
+API key saved in /home/you/.config/cytetype/credentials.json.
+Dashboard:  https://cytetype.nygen.io/dashboard
+```
+
+The prompt reads the key from your terminal. A notebook has no terminal to type into, so open one (in JupyterLab: **File**, **New**, **Terminal**) or pass the key to `run()` as `auth_token` instead (see [Sign in and API keys](../getting-started/sign-in.md)).
+
+### `cytetype logout`
+
+```text
+cytetype logout
+```
+
+Deletes the saved credentials file on this computer and prints `Local API key removed. Revoke it from the dashboard if needed.`, or `No local CyteType API key was found.` if there was none. The key itself keeps working wherever else it is saved or used. To stop it from working everywhere, disable it in the [dashboard](../platform/dashboard.md).
+
+### `cytetype dashboard`
+
+```text
+cytetype dashboard
+```
+
+Prints the dashboard address of the server you are signed in to and opens it in your browser. Without a saved sign-in, it opens `https://cytetype.nygen.io/dashboard`. The command takes no options and does not use `CYTETYPE_API_URL`.
+
+### `cytetype view`
+
+```text
+cytetype view JOB_ID
+```
+
+| Argument | Description |
+|---|---|
+| `JOB_ID` | The job ID, as printed when the job starts and listed in the dashboard. |
+
+Prints and opens the report of a job. The link goes through the sign-in page of the server you are signed in to (without a saved sign-in, `https://cytetype.nygen.io`), so you may be asked to sign in first. If the browser is already signed in, you go straight to the report. The command does not check that the job exists or that you may open it; the report page does that. It takes no `--api-url` option and does not use `CYTETYPE_API_URL`.
+
+### `cytetype --version` and `--help`
+
+`cytetype --version` prints `cytetype `, for example `cytetype 0.21.0`. `cytetype --help` (or `-h`) lists the commands, and `cytetype COMMAND --help` describes one command.
+
+(cli-cytetyper)=
+## `cytetyper` (R)
+
+### Install the launcher
+
+```r
+library(CyteTypeR)
+InstallCyteTypeRCli()
+```
+
+`InstallCyteTypeRCli()` copies a launcher named `cytetyper` (`cytetyper.cmd` on Windows) into a folder, prints `Installed .` and returns the path invisibly.
+
+| Argument | Default | Description |
+|---|---|---|
+| `destination` | `NULL`: the folder that contains the `Rscript` found on your `PATH` | Where to install the launcher. The launcher runs the `Rscript` in its own folder, so keep the default unless the folder you choose also contains `Rscript`. |
+| `overwrite` | `FALSE` | Replace an existing launcher. Without it, the function stops with `The cytetyper command already exists at . Set overwrite = TRUE to replace it.` |
+
+Other errors you may see:
+
+| Message | Meaning |
+|---|---|
+| `Could not locate Rscript on PATH` | R's `bin` folder is not on the `PATH` of your R session. Pass the folder of the running R's `Rscript`, `InstallCyteTypeRCli(destination = R.home("bin"))`, or add the folder to `PATH`. |
+| `CLI destination directory does not exist` | The `destination` folder does not exist. |
+| `Could not install cytetyper in . Check that the directory is writable.` | You cannot write to that folder, which is common with a system-wide R. Ask your administrator, or use the R functions instead. |
+| `Could not make the cytetyper command executable` | The copied file could not be made executable (Linux and macOS). |
+
+The launcher starts R with `Rscript --vanilla`, which skips your `.Rprofile` and `.Renviron` files. CyteTypeR must therefore be installed in a library that R finds without them (not only in a project library such as renv), and `CYTETYPE_API_URL` must be set in the shell, not in `.Renviron`. See [Installation](../getting-started/installation.md#installation-cytetyper) for the other requirements.
+
+### Usage
+
+```text
+cytetyper setup [--api-url URL] [--force]
+cytetyper get-key [--api-url URL] [--force]
+cytetyper login [--api-url URL]
+cytetyper logout
+cytetyper dashboard
+cytetyper view JOB_ID [--api-url URL]
+cytetyper --version
+```
+
+`cytetyper` without arguments, `cytetyper -h` and `cytetyper --help` print this help; `cytetyper COMMAND --help` describes one command. Give options as `--api-url URL` or `--api-url=URL`, each at most once.
+
+Each command calls the R function shown in [Commands at a glance](#cli-commands), so it behaves like the Python command of the same name described above. The differences:
+
+- `cytetyper login` reads the key from standard input. Type or paste it at the `API key:` prompt (on Linux and macOS, what you type is not shown), or pipe it in, for example `printf '%s\n' "$CYTETYPE_API_TOKEN" | cytetyper login`. An empty key stops the command with `Error: API key is required`.
+- `cytetyper view` accepts `--api-url`. Without it, it uses the server you are signed in to, then `CYTETYPE_API_URL`, then `https://cytetype.nygen.io`.
+- `cytetyper --version` prints `cytetyper `, for example `cytetyper 0.6.0`.
+- Usage errors (an unknown command or option, a missing `JOB_ID`) end with exit status 1, not 2.
+- If `setup` cannot open a browser, R warns `Could not launch a web browser.` instead of printing `Was not able to launch web browser`. Copy the printed link in either case.
+
+### The R functions
+
+| Function | Arguments | Returns (invisibly) |
+|---|---|---|
+| `SetupCyteTypeR()` | `api_url = NULL`: the server, defaulting as for `--api-url`. `force = FALSE`: skip the check of the saved key. | The saved account details: server, dashboard address, email, user and key IDs. Never the key itself. |
+| `LoginCyteTypeR()` | `api_token = NULL`: the key; when `NULL`, a hidden prompt asks for it in an interactive session, and otherwise the key is read from the first line of standard input. `api_url = NULL`: the server. | The saved account details, as above. |
+| `LogoutCyteTypeR()` | None | `TRUE` if a saved key was deleted, otherwise `FALSE`. |
+| `OpenCyteTypeDashboard()` | None | The dashboard address it opened. |
+| `ViewCyteTypeJob()` | `job_id`: the job ID. `api_url = NULL`: the server. | The address it opened. |
+| `InstallCyteTypeRCli()` | `destination = NULL`, `overwrite = FALSE` | The path of the installed launcher. |
+
+Because `LoginCyteTypeR()` accepts the key as an argument, it is useful on machines where a prompt is inconvenient:
+
+```r
+LoginCyteTypeR(api_token = Sys.getenv("CYTETYPE_API_TOKEN"))
+```
+
+The variable name is your choice: CyteTypeR does not read it on its own.
+
+(cli-server-url)=
+## Server URL and `CYTETYPE_API_URL`
+
+The default server is `https://cytetype.nygen.io`. You only need another address if you were given one, for example for a development server.
+
+`setup`, `get-key` and `login` (and the R functions `SetupCyteTypeR()` and `LoginCyteTypeR()`) choose the server in this order:
+
+1. `--api-url` (R: the `api_url` argument);
+2. the `CYTETYPE_API_URL` environment variable;
+3. `https://cytetype.nygen.io`.
+
+The same variable sets the default server of `CyteType()` in Python and of the CyteTypeR functions. In an R session, when the variable is not set, the R option `cytetype.default.api.url` is used if you set it.
+
+```bash
+export CYTETYPE_API_URL=https://cytetype.example.org
+cytetype setup
+```
+
+Both clients accept only a server origin:
+
+- a scheme, a host name and an optional port, with no path, query string, fragment or user name (a trailing `/` is removed);
+- HTTPS, except for `http://localhost` and `http://127.0.0.1`.
+
+Anything else fails with `API URL must be an HTTP or HTTPS server origin` or `Non-local API URLs must use HTTPS`. In Python, a `CYTETYPE_API_URL` that is set but empty is also rejected; unset it instead (`unset CYTETYPE_API_URL`).
+
+The saved sign-in belongs to one server. Signing in to another server replaces it, and the clients do not send a key saved for one server to another.
+
+(cli-credentials)=
+## Saved credentials
+
+`setup`, `get-key` and `login` write a file named `credentials.json`: in `$XDG_CONFIG_HOME/cytetype/` when that variable is set, otherwise in `%APPDATA%\cytetype\` on Windows and in `~/.config/cytetype/` on Linux and macOS. [Where the key is saved](../getting-started/sign-in.md#sign-in-credentials) describes how the file is protected.
+
+- There is one file per user account on the computer. The Python and R clients and both command-line tools share it.
+- It holds the sign-in for one server: the API key as plain text, the server and dashboard addresses, your email, and your user and key IDs. Do not share it, commit it or copy it into notebooks.
+- No environment variable holds the API key itself. In scripts and CI, pass the key as `auth_token` to `run()` or `CyteTypeR()` (see [Sign in and API keys](../getting-started/sign-in.md)).
+
+## Environment variables
+
+| Variable | Read by | Effect |
+|---|---|---|
+| `CYTETYPE_API_URL` | `setup`, `get-key` and `login` in both tools; `cytetyper view` and `ViewCyteTypeJob()` without a saved sign-in; `CyteType()` and the CyteTypeR functions | The default server origin. |
+| `XDG_CONFIG_HOME` | Both clients | The folder that holds `cytetype/credentials.json`. |
+| `APPDATA` | Both clients, on Windows | The folder that holds `cytetype\credentials.json` when `XDG_CONFIG_HOME` is not set. |
+
+## Output and exit codes
+
+`cytetype` prints results to standard output and errors to standard error, as `Error: `. `cytetyper` also prints errors as `Error: `, and prints its other messages, such as links and the signed-in email, to standard error too.
+
+| Exit status | `cytetype` | `cytetyper` |
+|---|---|---|
+| 0 | Success, including `--help` and `--version` | Success, including help and `--version` |
+| 1 | The command failed: for example a rejected key, a timeout, a network error, an invalid server URL or a damaged credentials file | The command failed, including usage errors and `Error: Unable to load CyteTypeR: ...` |
+| 2 | Usage error: an unknown command or option, or a missing `JOB_ID` | Not used |
+| 130 | Interrupted with Ctrl+C; prints `Cancelled.` | Interrupted; prints `Cancelled.` |
+
+Messages you may see, and what to do:
+
+| Message | Cause | What to do |
+|---|---|---|
+| `CyteType setup timed out` | The browser step was not finished within 5 minutes, or the browser could not reach the listener on `127.0.0.1`. | Run the command again. See [Troubleshooting](../support/troubleshooting.md#troubleshooting-sign-in). |
+| `CyteType authorization was denied` | **Cancel** was selected on the authorization page. | Run the command again and select **Authorize CLI**. |
+| `Invalid API token` or `API token has expired or been deactivated`, followed by "Run `cytetype setup --force` ..." | The server rejected the saved key, usually because it was disabled. | `cytetype setup --force` (R: `SetupCyteTypeR(force = TRUE)`). |
+| `Invalid CyteType credentials file: ` | The saved file is damaged or incomplete. | `cytetype logout`, then `cytetype setup`. |
+| `Could not validate the saved API key` or `Could not validate the API key` | The server could not be reached. | Check your network or proxy, then try again. |
+| `API key is required` | The `login` prompt received an empty key. | Run `cytetype login` again and paste the key. |
+| `API URL must be an HTTP or HTTPS server origin` or `Non-local API URLs must use HTTPS` | `--api-url` or `CYTETYPE_API_URL` is not a valid server origin. | See [Server URL](#cli-server-url). |
+| `CyteType credentials directory is not owned by the current user: ` | The credentials folder belongs to another user, for example after running the command with `sudo`. | Fix the folder's owner, or set `XDG_CONFIG_HOME` to a folder of your own. |
+
+For errors that appear while you run jobs, see [Errors and error codes](errors.md).
diff --git a/docs/source/reference/errors.md b/docs/source/reference/errors.md
new file mode 100644
index 0000000..86fed5d
--- /dev/null
+++ b/docs/source/reference/errors.md
@@ -0,0 +1,359 @@
+---
+description: The exceptions raised by the CyteType Python client and the error conditions signalled by CyteTypeR, the server error codes behind them, and what to do about each.
+---
+
+(errors)=
+# Errors and error codes
+
+When something goes wrong, the Python client raises an exception and the R client stops with an error condition. Errors that come from the CyteType server carry a machine-readable code, such as `RATE_LIMIT_EXCEEDED`, and a message that explains the problem. This page lists the exceptions, the codes and what to do about each one. For problems described by their symptoms, see [Troubleshooting](../support/troubleshooting.md).
+
+(errors-python)=
+## Python exceptions
+
+All CyteType exceptions can be imported from `cytetype` (and from `cytetype.api`); the [Python API reference](../reference/api/exceptions.html) documents each class:
+
+```python
+from cytetype import (
+    CyteTypeError,
+    APIError,
+    AuthenticationError,
+    AuthorizationError,
+    RateLimitError,
+    QuotaExceededError,
+    JobNotFoundError,
+    JobFailedError,
+    LLMValidationError,
+    ClientDisconnectedError,
+    NetworkError,
+    CyteTypeTimeoutError,
+)
+```
+
+They form this hierarchy:
+
+```text
+CyteTypeError                      base class: .message, .error_code
+β”œβ”€β”€ APIError                       errors reported by the server (any code not listed below)
+β”‚   β”œβ”€β”€ AuthenticationError        INVALID_TOKEN, TOKEN_INACTIVE, AUTHENTICATION_REQUIRED
+β”‚   β”œβ”€β”€ AuthorizationError         ACCESS_DENIED, LICENSE_EXPIRED, VPN_BLOCKED
+β”‚   β”œβ”€β”€ RateLimitError             RATE_LIMIT_EXCEEDED
+β”‚   β”œβ”€β”€ QuotaExceededError         QUOTA_EXCEEDED
+β”‚   β”œβ”€β”€ JobNotFoundError           JOB_NOT_FOUND
+β”‚   β”œβ”€β”€ JobFailedError             JOB_FAILED, or the job's status became "failed"
+β”‚   β”œβ”€β”€ LLMValidationError         LLM_VALIDATION_FAILED
+β”‚   └── ClientDisconnectedError    CLIENT_DISCONNECTED
+β”œβ”€β”€ NetworkError                   the server could not be reached, or its reply could not be read
+└── TimeoutError                   a request, or the wait for a job, took too long
+                                   (import it as CyteTypeTimeoutError)
+```
+
+Every CyteType exception has two attributes:
+
+- `message`: the human-readable message, also returned by `str(err)`;
+- `error_code`: the server's code, such as `"QUOTA_EXCEEDED"`, or a code set by the client. It is `None` for some client-side errors, for example a job that failed while you were waiting for it or a job that did not finish within `timeout_seconds`.
+
+A few points to keep in mind:
+
+- **`CyteTypeTimeoutError` is not Python's built-in `TimeoutError`.** An `except TimeoutError:` clause with the built-in class does not catch it. Catch `CyteTypeTimeoutError` or `CyteTypeError`. `cytetype.api` exports the class under both names, `CyteTypeTimeoutError` and `TimeoutError`, so after `from cytetype.api import *` the name `TimeoutError` means CyteType's class instead of the built-in one. `from cytetype import *` imports only `CyteTypeTimeoutError`.
+- **Codes without their own class** (for example `INTERNAL_ERROR`, `HTTP_ERROR` and `REPORT_DELETED`) raise a plain `APIError`. Check `err.error_code` to tell them apart.
+- **`JobNotFoundError` is rarely raised.** When the results of a job cannot be found, the client raises a plain `APIError` with `error_code == "JOB_NOT_FOUND"`. Check the code rather than the class.
+- **`get_results()` does not raise server errors.** If fetching fails, it logs the reason (for example "Failed to retrieve results for job ...") and returns `None`. It raises only `AuthenticationError` when no API key is available for the job's server, and `ValueError` when the server address stored with the job is not valid.
+
+(errors-python-local)=
+### Errors raised before anything is sent
+
+Problems with your data or arguments are found on your computer and raise standard Python exceptions:
+
+| Exception | Raised by | Typical causes |
+|---|---|---|
+| `KeyError` | `CyteType()` | `group_key` is not a column of `adata.obs`; the `rank_genes_groups` results are not in `adata.uns` under `rank_key`; `gene_symbols_column` is not a column of `adata.var`. |
+| `ValueError` | `CyteType()` | Missing cluster labels (`NaN`) without `label_na=True`; gene IDs instead of gene symbols ("Gene Symbol Detection Error"); `rank_genes_groups` computed with another `groupby`; marker genes that do not match `adata.var_names`; an invalid `api_url`. |
+| `ValueError` | `run()` | Results with the same `results_prefix` already exist; `visibility` is not `"private"`, `"org"`, `"public"` or `None`; `upload_timeout_seconds` is not positive; `cleanup()` deleted the files and `require_artifacts=True`; an invalid `api_url`. |
+| `pydantic.ValidationError` | `run()` | `metadata` values that are not strings; an `llm_configs` entry with an unknown provider or without `apiKey` (or the AWS fields); `n_parallel_clusters` outside 1 to 50; a `study_context` that is not a string. |
+
+`pydantic.ValidationError` is a subclass of `ValueError`, so `except ValueError` catches it too. These checks run before any file is uploaded. For the fixes, see [Troubleshooting](../support/troubleshooting.md#troubleshooting-data).
+
+With `require_artifacts=True` (the default), a failure while building or uploading `vars.h5` or `obs.duckdb` re-raises the original exception, for example an `OSError` when the disk is full or a `ValueError` when a file is larger than the upload limit. See [Artifacts and uploads](../support/troubleshooting.md#troubleshooting-artifacts).
+
+(errors-when)=
+### When each error can occur in `run()`
+
+`run()` works through these steps in order, so the step tells you how far a job got:
+
+1. **Checks on the arguments and the `CyteType` object:** `ValueError`.
+2. **Finding your API key:** `AuthenticationError` with `AUTHENTICATION_REQUIRED` if no key is saved for the server and none was passed. With `visibility="org"` and a key that looks like a personal key (`cyt_p_...`), `run()` then logs a warning; only the server can refuse the key, at step 5.
+3. **Validating the request:** `pydantic.ValidationError`.
+4. **Uploading `vars.h5` and `obs.duckdb`:** a rejected key raises `AuthenticationError` and a refused account (for example an expired license) raises `AuthorizationError`, even with `require_artifacts=False`. Other upload failures raise only with `require_artifacts=True`.
+5. **Submitting the job:** `RateLimitError`, `QuotaExceededError`, `LLMValidationError`, or `APIError` (for example "Org visibility requires an organization license" when `visibility="org"` is used with a key that has no organization license, such as a personal API key). All job limits and quotas are checked here, after the upload, so fixing these errors means uploading again.
+6. **Waiting for the job:** `JobFailedError` if the job fails, `CyteTypeTimeoutError` if it does not finish within `timeout_seconds`. Brief network problems while waiting are retried. Once the job has been submitted, its ID is stored in `adata.uns` and you can fetch the results later with `get_results()`.
+
+(errors-codes)=
+## Server error codes
+
+The table summarizes the codes; the sections below give the typical causes and fixes.
+
+| Code | Python exception | In short |
+|---|---|---|
+| `INVALID_TOKEN` | `AuthenticationError` | The API key is not recognized. |
+| `TOKEN_INACTIVE` | `AuthenticationError` | The API key was disabled. |
+| `AUTHENTICATION_REQUIRED` | `AuthenticationError` | No API key was available. |
+| `ACCESS_DENIED` | `AuthorizationError` | You may not access this job or resource. |
+| `LICENSE_EXPIRED` | `AuthorizationError` | Your organization's license has expired. |
+| `VPN_BLOCKED` | `AuthorizationError` | The request was refused because of the network it came from. |
+| `RATE_LIMIT_EXCEEDED` | `RateLimitError` | Your personal account reached a job limit. |
+| `QUOTA_EXCEEDED` | `QuotaExceededError` | Not enough clusters left in the quota. |
+| `JOB_NOT_FOUND` | `APIError` or `JobNotFoundError` | No job with this ID on this server. |
+| `JOB_FAILED` | `JobFailedError` | The job failed on the server. |
+| `LLM_VALIDATION_FAILED` | `LLMValidationError` | A model in `llm_configs` failed the check before the job started. |
+| `CLIENT_DISCONNECTED` | `ClientDisconnectedError` | The connection closed before the server had the whole request. |
+| Other codes | `APIError` | See [Other codes](#errors-other-codes). |
+
+(errors-invalid-token)=
+### `INVALID_TOKEN`
+
+The server does not recognize the API key: "Invalid API token".
+
+- **Typical causes:** a mistyped or truncated key; a key from another CyteType server than the one in `api_url`.
+- **What to do:** sign in again with `cytetype setup --force` (R: `SetupCyteTypeR(force = TRUE)`), or save a correct key with `cytetype login` (R: `LoginCyteTypeR()`). If you pass `auth_token`, check its value and that `api_url` points to the server that issued it.
+
+(errors-token-inactive)=
+### `TOKEN_INACTIVE`
+
+The key exists but has been disabled: "API token has expired or been deactivated".
+
+- **Typical causes:** you, or an admin of your organization, disabled the key in the dashboard. Disabling a key cannot be undone. Because browser sign-in reuses your "CyteType CLI" key, disabling it affects every computer that signed in with `cytetype setup`.
+- **What to do:** run `cytetype setup --force` (R: `SetupCyteTypeR(force = TRUE)`) to get a new key, or save another active key with `cytetype login`. Jobs that were already running still finish; fetch their results with a valid key.
+
+(errors-authentication-required)=
+### `AUTHENTICATION_REQUIRED`
+
+No API key was available.
+
+- **Typical causes:** you have not signed in on this computer; the saved sign-in belongs to a different server than the one in `api_url` or `CYTETYPE_API_URL`; the saved credentials file is damaged or cannot be read (for example in a container without a home directory).
+- **Messages:** "CyteType sign-in is required. Run `cytetype setup` first." (R: "Run SetupCyteTypeR() first."), or "Invalid CyteType credentials file: ...". An outdated client may show the server's message, "Authentication is required. Upgrade to the latest CyteType or CyteTypeR and run setup, or provide a valid API token."
+- **What to do:** run `cytetype setup` (R: `SetupCyteTypeR()`) for the same server you use in your code, or pass `auth_token`. For a damaged file, run `cytetype logout` (R: `LogoutCyteTypeR()`) and sign in again. See [Sign in and API keys](../getting-started/sign-in.md).
+
+(errors-access-denied)=
+### `ACCESS_DENIED`
+
+You may not access this job or resource: "You do not have permission to access this job".
+
+- **Typical causes:** fetching the results of a job that you do not own and that is neither public nor an organization report of your organization. Organization reports can be fetched only with an organization API key of the same organization, not with a member's personal key. Sharing a report by email lets the recipient open it in the browser but does not give their API key access to its results. When you fetch results, an unknown key is also reported this way ("Invalid token or you do not have permission to access this job").
+- **What to do:** fetch the results with the owner's key or an organization key, ask the owner to change the [visibility](../platform/sharing.md), or open the report in your browser if it was shared with you and use **Download as CSV**.
+
+(errors-license-expired)=
+### `LICENSE_EXPIRED`
+
+The organization license that the key belongs to has expired: "License has expired. Please contact your organization admin(s): ...".
+
+- **Typical causes:** you use an organization key (starting with `cyt_`) after the license period ended. An expired license blocks uploads and new jobs.
+- **What to do:** contact your organization's admins, or contact@nygen.io to renew the license. In Python, this error stops `run()` during the upload with "Artifact upload was not authorized".
+
+(errors-vpn-blocked)=
+### `VPN_BLOCKED`
+
+The server refused the request because of the network it came from.
+
+- **Typical causes:** the request was sent through a VPN, proxy or similar service.
+- **What to do:** try again from your institution's network without the VPN or proxy. If you think the request was refused by mistake, contact contact@nygen.io.
+
+(errors-rate-limit)=
+### `RATE_LIMIT_EXCEEDED`
+
+Your personal account reached one of its job limits. The message names the limit and when it resets, for example "Daily limit reached (10/10). Resets in about 5 hours."
+
+- **Typical causes:** personal accounts can run 10 jobs in any rolling 24 hours, 50 in 7 days and 100 in 30 days. Running and completed jobs count, including deleted ones; failed jobs do not. The limits also apply when you use your own LLM.
+- **What to do:** wait until the time given in the message, or email contact@nygen.io about a free academic license with higher limits. The limit is checked when the job is submitted, after the files are uploaded. See [Usage limits and quotas](../platform/limits.md).
+
+(errors-quota-exceeded)=
+### `QUOTA_EXCEEDED`
+
+The job has more clusters than remain in the quota: "Quota exceeded: This job requires 24 clusters but you only have 10 remaining (990/1000 used)".
+
+- **Typical causes:** your organization's license quota is used up, or the key has its own cluster limit (set when the key was created or by an admin). Clusters of running and completed jobs count, including deleted jobs.
+- **What to do:** ask your organization's admins to raise the key's limit or the license quota, or use another key. The quota is checked when the job is submitted, after the files are uploaded. See [Usage limits and quotas](../platform/limits.md).
+
+(errors-job-not-found)=
+### `JOB_NOT_FOUND`
+
+The server has no job with this ID: "Job ... not found" (Python: "Results not found for job ...").
+
+- **Typical causes:** a mistyped job ID; asking a different server (`api_url`) than the one that ran the job.
+- **What to do:** copy the job ID from the dashboard or from the report link, and use the server the job ran on. In Python, this is usually a plain `APIError` with `error_code == "JOB_NOT_FOUND"`.
+
+(errors-job-failed)=
+### `JOB_FAILED`
+
+The job failed on the server. In Python, `run()` raises `JobFailedError` with "Job ... failed" as soon as the job's status becomes failed, and prints the report link.
+
+- **Typical causes:** a problem on the server, or a job that ran longer than the server allows (4 hours).
+- **What to do:** open the report to see how far the job got, then run it again, with `override_existing_results=True` or another `results_prefix` if you reuse the same `CyteType` object. Failed jobs do not count toward the personal job limits. If it fails again, contact support with the job ID (see [Get help](../support/troubleshooting.md#troubleshooting-help)).
+
+(errors-llm-validation)=
+### `LLM_VALIDATION_FAILED`
+
+A model in `llm_configs` did not pass the check that runs before the job starts: "All provided models failed validation: ... Ensure the model is reachable and supports tool calling."
+
+- **Typical causes:** a wrong model name or API key; a `baseUrl` that CyteType's servers cannot reach (for example a local address); a model without tool calling. If any of the models fails, the whole request is rejected.
+- **What to do:** check each configuration, make sure the endpoint is reachable from the internet and that the model supports tool calling, then run again. The check happens at submission, after the files are uploaded. See [Use your own LLM](../guides/llm-configuration.md).
+
+(errors-client-disconnected)=
+### `CLIENT_DISCONNECTED`
+
+The connection closed before the server had received the whole request: "Client closed the connection before the request completed".
+
+- **Typical causes:** an unstable network, or a proxy or firewall that cuts long requests, usually during an upload.
+- **What to do:** check your connection and run again.
+
+(errors-other-codes)=
+### Other codes
+
+These codes raise a plain `APIError` (or `NetworkError`, where noted):
+
+| Code | Meaning | What to do |
+|---|---|---|
+| `INTERNAL_ERROR` | An unexpected error on the server ("An unexpected error occurred: ..."). | Try again later. The Python client retries each part of a file upload up to three times on its own. If the error persists, contact support with the message. |
+| `HTTP_ERROR` | A request was refused for another reason; the message says why, for example "Org visibility requires an organization license". It is a `NetworkError` when the reply could not be read at all, for example an error page from a proxy. | Act on the message. |
+| `REPORT_DELETED` | The owner deleted the report ("This report has been deleted by the owner"). | Deletion cannot be undone. Ask the owner if they have another copy of the results. |
+| `JOB_PROCESSING`, `JOB_NOT_COMPLETED` | The job has not finished yet. | Wait and fetch the results again. |
+| `INVALID_RESPONSE` | Set by the client: the server's reply was not in the expected form. | Try again; if it persists, update the client and contact support. |
+| `PRESIGNED_URL_REJECTED`, `MISSING_ETAG` | Set by the client: the file storage service rejected part of an upload (`MISSING_ETAG` is a `NetworkError`). | Run again. If it keeps happening on a slow connection, see [Artifacts and uploads](../support/troubleshooting.md#troubleshooting-artifacts). |
+
+(errors-python-example)=
+## Catch and handle errors in Python
+
+Catch the specific classes you can act on, then `CyteTypeError` for the rest:
+
+```python
+from cytetype import (
+    AuthenticationError,
+    CyteType,
+    CyteTypeError,
+    CyteTypeTimeoutError,
+    JobFailedError,
+    LLMValidationError,
+    QuotaExceededError,
+    RateLimitError,
+)
+
+annotator = CyteType(adata, group_key="leiden", rank_key="rank_genes_leiden")
+
+try:
+    adata = annotator.run(study_context="Human PBMC from a healthy donor")
+except AuthenticationError as err:
+    print(f"API key problem ({err.error_code}): {err.message}")
+    print("Run `cytetype setup --force`, or pass a valid auth_token.")
+except RateLimitError as err:
+    print(f"Job limit reached: {err.message}")
+except QuotaExceededError as err:
+    print(f"Quota exceeded: {err.message}")
+except LLMValidationError as err:
+    print(f"A model in llm_configs failed validation: {err.message}")
+except JobFailedError as err:
+    print(f"The job failed on the server: {err.message}")
+except CyteTypeTimeoutError as err:
+    # A submitted job keeps running on the server.
+    print(f"Timed out: {err.message}")
+    print("Fetch the results later with annotator.get_results().")
+except CyteTypeError as err:
+    print(f"CyteType error ({err.error_code}): {err.message}")
+    raise
+```
+
+`run()` returns the annotated AnnData object, so assign its return value (here back to `adata`). With `label_na=True`, it can be a new object.
+
+To retry automatically after a rate limit, read the reset time from the message and run again later. Do not retry `AuthenticationError`, `AuthorizationError` or `QuotaExceededError` in a loop: they need a change on your side.
+
+(errors-r)=
+## R errors
+
+CyteTypeR signals errors in these classes:
+
+```text
+error
+β”œβ”€β”€ cytetype_api_error         a problem with the CyteType API: e$message, e$error_code, e$call ("network" or "api")
+β”‚   └── cytetype_auth_error    sign-in and API key problems: e$message, e$error_code
+β”œβ”€β”€ cytetype_timeout_error     CyteTypeR() stopped waiting at timeout_seconds: e$message, e$job_id
+└── simple R errors            everything else, including most refusals from the server
+```
+
+**`cytetype_auth_error`** conditions (which also inherit `cytetype_api_error`) carry an `error_code`:
+
+| `error_code` | When |
+|---|---|
+| `AUTHENTICATION_REQUIRED` | No key is saved for the server ("CyteType sign-in is required. Run SetupCyteTypeR() first."), the credentials file is damaged ("Invalid CyteType credentials file: ... Run LogoutCyteTypeR(), then SetupCyteTypeR()."), or `GetResults()` was given `auth_token` for a job stored in the object without `api_url`. |
+| `API_ORIGIN_MISMATCH` | `GetResults()` was given an `api_url` that differs from the server stored with the job. |
+| `INVALID_TOKEN`, `TOKEN_INACTIVE`, `LICENSE_EXPIRED` | `SetupCyteTypeR()` or `LoginCyteTypeR()` was refused by the server. `INVALID_TOKEN` is also used when a request for a job's results is refused with "Authentication failed: Invalid or expired auth token". |
+| `ACCESS_DENIED` | The server refused to return a job's results ("Authorization failed: Access denied"): the key is unknown or disabled, or the job is neither yours, nor an organization report of the key's organization, nor public. Also used when you select **Cancel** on the authorization page during setup. |
+| `TIMEOUT`, `NETWORK_ERROR`, `CALLBACK_SERVER_ERROR`, `INVALID_RESPONSE`, `AUTHENTICATION_FAILED` | Browser sign-in or key validation failed: it was not finished within 5 minutes, the server could not be reached, the local listener could not start, the server's reply was not as expected, or another refusal. |
+
+**`cytetype_timeout_error`** means that `CyteTypeR()` stopped waiting after `timeout_seconds` (default 7200). The job keeps running on the server. `e$job_id` holds its ID, and the message names the call that fetches its results: "Job  did not finish within 7200 seconds (timeout_seconds). The job keeps running on the server; retrieve its results later with GetResults(job_id = "")."
+
+**`cytetype_api_error`** is the parent class of `cytetype_auth_error`, so a handler for it also catches API key problems. CyteTypeR signals it when a job's status or results cannot be fetched: by `GetResults()`, and by `CyteTypeR()` while it waits (after five consecutive errors, or when a completed job's results cannot be fetched or are not in the expected form). `e$error_code` holds the server's code when it sent one, such as `REPORT_DELETED` or `RATE_LIMIT_EXCEEDED`. An uncaught one prints as `Error: `; `print(e)` shows `CyteType API Error: `.
+
+**Simple R errors** cover the rest. When the server refuses an upload or a job submission, the message names the step and gives the server's explanation, with its code in brackets when the server sent one:
+
+```text
+Upload initiate failed: [TOKEN_INACTIVE] API token has expired or been deactivated
+Job submission failed: [QUOTA_EXCEEDED] Quota exceeded: This job requires 24 clusters but you only have 10 remaining (990/1000 used)
+Job submission failed: [LLM_VALIDATION_FAILED] All provided models failed validation: ...
+```
+
+Other messages you may see:
+
+- **Job limit:** the server's message, such as "Daily limit reached (10/10). Resets in about 5 hours. ...", followed by "The limits apply to every job, including jobs that use your own LLM through llm_configs. Wait for the limit to reset before retrying."
+- **Job failed:** "Server error: Job failed" from `CyteTypeR()`, "Job failed" from `GetResults()`.
+- **Polling stopped:** a `cytetype_api_error` "Stopping after 5 consecutive errors: ..." with "The job may still be running; retrieve its results later with GetResults(job_id = "").", or the simple error "Job '...' not found after 5 attempts. Verify the job_id is correct." When the server cannot be reached, `CyteTypeR()` keeps retrying until `timeout_seconds` runs out instead.
+- **Results not fetched:** `GetResults()` signals a `cytetype_api_error` with the reason, for example "Error while checking job status: HTTP 410 [REPORT_DELETED] This report has been deleted by the owner" (with `e$error_code` set to `"REPORT_DELETED"`); a failed results request reads "Error while fetching job results: ...". While a job is still running, and for a job ID the server does not know, it returns `NULL` with a message instead.
+- **Upload failures:** for example "Upload chunk 3 failed: ..." or "Upload complete failed: ...".
+- **Organization visibility:** with `visibility = "org"` and a key that looks like a personal key (`cyt_p_...`), `CyteTypeR()` warns before the upload. If the key has no organization license, the server then refuses the job with `Job submission failed: [HTTP_ERROR] Org visibility requires an organization license`.
+- **Checks before submission:** for example "All infoTags values must be character strings", "n_parallel_clusters must be between 1 and 50", "visibility must be "private", "org", "public" or NULL", "Results with prefix 'cytetype' already exist in this object ...", "Artifact build did not succeed. Set require_artifacts = FALSE to continue without artifacts." and the `llm_configs` checks, such as "provider must be one of: ..." or "Either apiKey or all AWS credentials must be provided". See [Troubleshooting](../support/troubleshooting.md).
+
+(errors-r-example)=
+## Catch and handle errors in R
+
+Handle `cytetype_auth_error`, `cytetype_timeout_error` and `cytetype_api_error` first (their fields name the problem), then match server codes in the message of other errors:
+
+```r
+annotated <- tryCatch(
+  CyteTypeR(
+    obj = pbmc,
+    prepped_data = prepped_data,
+    study_context = "Human PBMC from a healthy donor"
+  ),
+  cytetype_auth_error = function(e) {
+    message("API key problem (", e$error_code, "): ", conditionMessage(e))
+    message("Run SetupCyteTypeR(force = TRUE) or LoginCyteTypeR(), then try again.")
+    NULL
+  },
+  cytetype_timeout_error = function(e) {
+    # The job keeps running on the server.
+    message("Timed out: ", conditionMessage(e))
+    message("Fetch the results later with GetResults(job_id = \"", e$job_id, "\").")
+    NULL
+  },
+  cytetype_api_error = function(e) {
+    # The job's status or results could not be fetched; the job may still be running.
+    code <- if (is.null(e$error_code)) "no code" else e$error_code
+    message("CyteType API error (", code, "): ", conditionMessage(e))
+    NULL
+  },
+  error = function(e) {
+    msg <- conditionMessage(e)
+    if (grepl("INVALID_TOKEN|TOKEN_INACTIVE", msg)) {
+      message("The API key was rejected. Run SetupCyteTypeR(force = TRUE).")
+    } else if (grepl("limit reached", msg, fixed = TRUE)) {
+      message("Job limit reached: ", msg)
+    } else if (grepl("QUOTA_EXCEEDED", msg, fixed = TRUE)) {
+      message("Quota exceeded: ", msg)
+    } else {
+      stop(e)
+    }
+    NULL
+  }
+)
+
+if (!is.null(annotated)) pbmc <- annotated
+```
+
+`CyteTypeR()` returns the annotated Seurat object, so keep its return value. If it stops with an error, your original object is unchanged.
diff --git a/docs/source/reference/r-api.md b/docs/source/reference/r-api.md
new file mode 100644
index 0000000..d58816a
--- /dev/null
+++ b/docs/source/reference/r-api.md
@@ -0,0 +1,566 @@
+---
+description: The exported functions of the CyteTypeR R package, with their usage, arguments, return values and examples, generated from the package's help pages.
+---
+
+(r-api)=
+# R function reference
+
+This page is generated from the help pages of CyteTypeR 0.6.0. To regenerate it, run `Rscript docs/render_r_reference.R ../CyteTypeR` from the root of the CyteType repository. In R, the same help is available with `?` and the function name, for example `?PrepareCyteTypeR`.
+
+| Function | Section | Purpose |
+| --- | --- | --- |
+| [`PrepareCyteTypeR()`](#r-api-preparecytetyper) | Prepare and run | Prepare Seurat Object for CyteType Analysis |
+| [`CyteTypeR()`](#r-api-cytetyper) | Prepare and run | Submit CyteType Analysis Job |
+| [`CleanUpArtifacts()`](#r-api-cleanupartifacts) | Prepare and run | Clean Up Artifacts |
+| [`GetResults()`](#r-api-getresults) | Results | Retrieve CyteType Analysis Results |
+| [`SetupCyteTypeR()`](#r-api-setupcytetyper) | Sign-in and the cytetyper command | Set up CyteType authentication |
+| [`LoginCyteTypeR()`](#r-api-logincytetyper) | Sign-in and the cytetyper command | Save an existing CyteType API key |
+| [`LogoutCyteTypeR()`](#r-api-logoutcytetyper) | Sign-in and the cytetyper command | Remove local CyteType credentials |
+| [`OpenCyteTypeDashboard()`](#r-api-opencytetypedashboard) | Sign-in and the cytetyper command | Open the CyteType dashboard |
+| [`ViewCyteTypeJob()`](#r-api-viewcytetypejob) | Sign-in and the cytetyper command | Open a CyteType job |
+| [`InstallCyteTypeRCli()`](#r-api-installcytetypercli) | Sign-in and the cytetyper command | Install the cytetyper terminal command |
+| [`cytetype_api_error()`](#r-api-cytetype-api-error) | Errors | Custom API Error |
+
+(r-api-prepare-and-run)=
+## Prepare and run
+
+Prepare a Seurat object, submit the annotation job and wait for the results, then delete the uploaded files.
+
+(r-api-preparecytetyper)=
+### `PrepareCyteTypeR()`
+
+*Prepare Seurat Object for CyteType Analysis*
+
+Prepares a Seurat object and a gene marker table for CyteType API by extracting and formatting cluster labels, metadata, marker genes, visualization coordinates, and expression data into the required data structure. It also writes the two files that [`CyteTypeR()`](#r-api-cytetyper) uploads with the job: `vars.h5` (expression) and `obs.duckdb` (cell metadata and coordinates).
+
+**Usage**
+
+```r
+PrepareCyteTypeR(
+  obj,
+  marker_table,
+  group_key = "seurat_clusters",
+  gene_symbols = "gene_symbols",
+  n_top_genes = 50,
+  aggregate_metadata = TRUE,
+  min_percentage = 10,
+  max_metadata_categories = 500L,
+  pcent_batch_size = 5000,
+  coordinates_key = "umap",
+  max_cells_per_group = 1000,
+  vars_h5_path = "vars.h5",
+  obs_duckdb_path = "obs.duckdb"
+)
+```
+
+**Arguments**
+
+| Argument | Description |
+| --- | --- |
+| `obj` | A Seurat object with normalized data and clustering results. It needs an assay named `"RNA"` with a normalized `data` layer. |
+| `marker_table` | Data frame containing marker genes with the columns `cluster`, `gene` and `avg_log2FC`, such as the output of Seurat's `FindAllMarkers()`. Its clusters must be exactly the clusters in `group_key`, each with at least five marker genes. |
+| `group_key` | Character string specifying the metadata column containing cluster assignments. Every cell needs a cluster: `NA` values stop the preparation with an error. Default is `"seurat_clusters"`. |
+| `gene_symbols` | Character string naming the column of the RNA assay's feature metadata that holds gene symbols. It is used only to check that the genes are symbols rather than database identifiers; when the column does not exist, the row names of the RNA assay are checked instead. Default is `"gene_symbols"`. |
+| `n_top_genes` | Integer specifying the maximum number of top marker genes per cluster to include after ordering by `avg_log2FC`. Filter `marker_table` before calling this function when a fold-change threshold is required. Default is 50. |
+| `aggregate_metadata` | Logical indicating whether to aggregate metadata across cells within each cluster. Default is `TRUE`. |
+| `min_percentage` | Numeric threshold for minimum percentage. Default is 10. |
+| `max_metadata_categories` | Integer. Maximum number of unique values a categorical metadata column may have to be included in aggregation. Columns exceeding this limit are silently skipped. Default is 500. |
+| `pcent_batch_size` | Integer specifying batch size for expression percentage calculations. Default is 5000. |
+| `coordinates_key` | Character string naming the dimensional reduction used for the report's visualization, such as `"umap"` or `"tsne"`. This reduction is tried first, then `"umap"`, `"tsne"` and `"pca"`, matched case-insensitively; the first one with a row for every cell and at least two dimensions is used. When none qualifies, the job runs without visualization. Default is `"umap"`. |
+| `max_cells_per_group` | Integer specifying maximum cells per cluster for visualization subsampling. Default is 1000. |
+| `vars_h5_path` | Character string specifying the local file path for the generated vars.h5 artifact (feature expression). Default is `"vars.h5"`. |
+| `obs_duckdb_path` | Character string specifying the local file path for the generated obs.duckdb artifact (cell metadata). Default is `"obs.duckdb"`. |
+
+**Value**
+
+A named list to pass to [`CyteTypeR()`](#r-api-cytetyper):
+
+- `clusterLabels`: List mapping the CyteType cluster IDs (`"1"`, `"2"`, ...) to your cluster labels
+- `clusterMetadata`: Aggregated metadata by cluster (if enabled)
+- `markerGenes`: List of top marker genes per cluster
+- `visualizationData`: Sampled coordinates and cluster assignments for plotting, or `NULL` when no coordinates were found
+- `expressionData`: Expression percentages by cluster
+- `group_key`: The metadata column name used for cluster assignments
+- `build_succeeded`: Logical indicating whether artifact files were built successfully
+- `vars_h5_path`: Path to the generated vars.h5 file
+- `obs_duckdb_path`: Path to the generated obs.duckdb file
+- `coordinates_key`: The name of the reduction whose coordinates were used for the visualization, or `NULL` when none was used
+
+**Details**
+
+The function performs the following steps:
+
+1. Validates the Seurat object structure and the cluster labels
+2. Creates a mapping from original cluster labels to sequential numeric IDs
+3. Aggregates metadata across cells within clusters (optional)
+4. Filters and selects top marker genes per cluster
+5. Extracts dimensional reduction coordinates for visualization
+6. Calculates expression percentages across clusters
+7. Builds a vars.h5 artifact from the normalized expression matrix, with the raw counts when they are whole numbers
+8. Builds an obs.duckdb artifact from the cell metadata
+
+Clusters are renumbered sequentially (1, 2, 3, ...) in the sorted order of your labels (level order for a factor) to ensure consistent formatting regardless of original cluster naming.
+
+Expression always comes from the `"RNA"` assay, whatever the default assay is, and Seurat v5 layers split by sample are joined on a copy (your object is not changed). The gene names come from the same assay. When the default assay is not `"RNA"`, a warning says that `"RNA"` is used.
+
+Errors while building the two files are logged and recorded as `build_succeeded = FALSE`; [`CyteTypeR()`](#r-api-cytetyper) then stops unless `require_artifacts = FALSE`.
+
+**Examples**
+
+```r
+# Prepare Seurat object with UMAP coordinates
+prepped_data <- PrepareCyteTypeR(
+  obj = seurat_obj,
+  marker_table = markers_df,
+  group_key = "seurat_clusters",
+  n_top_genes = 30,
+  coordinates_key = "umap"
+)
+```
+
+**See also**
+
+[`CyteTypeR()`](#r-api-cytetyper) to submit the job, [`CleanUpArtifacts()`](#r-api-cleanupartifacts) to delete the files afterwards.
+
+(r-api-cytetyper)=
+### `CyteTypeR()`
+
+*Submit CyteType Analysis Job*
+
+Submits a prepared dataset to CyteType for automated cell type annotation using large language models. Handles job submission, polling for results, and integration of annotations back into the Seurat object.
+
+**Usage**
+
+```r
+CyteTypeR(
+  obj,
+  prepped_data,
+  study_context = NULL,
+  llm_configs = NULL,
+  metadata = NULL,
+  n_parallel_clusters = 2L,
+  results_prefix = "cytetype",
+  poll_interval_seconds = NULL,
+  timeout_seconds = NULL,
+  api_url = NULL,
+  auth_token = NULL,
+  save_query = TRUE,
+  query_filename = "query.json",
+  upload_timeout_seconds = 3600L,
+  upload_max_workers = 6L,
+  require_artifacts = TRUE,
+  show_progress = TRUE,
+  override_existing_results = FALSE,
+  visibility = NULL
+)
+```
+
+**Arguments**
+
+| Argument | Description |
+| --- | --- |
+| `obj` | A Seurat object (will be returned with annotations added). |
+| `prepped_data` | Named list containing prepared data from `PrepareCyteTypeR()`. |
+| `study_context` | Optional character. Biological context for the experimental setup (e.g. organisms, tissues, diseases, developmental stages, single-cell methods, experimental conditions). Default is `NULL`. |
+| `llm_configs` | Optional list of LLM configs. Each element must match the LLMModelConfig schema with required `provider` and `name`; either `apiKey` or all AWS credentials (`awsAccessKeyId`, `awsSecretAccessKey`, `awsDefaultRegion`) must be provided. `provider` is one of `"anthropic"`, `"bedrock"`, `"deepseek"`, `"fireworks"`, `"google"`, `"groq"`, `"huggingface"`, `"mistral"`, `"nebius"`, `"openai"`, `"openrouter"`, `"together"`, `"vertex"` or `"xai"`. Default is `NULL` (API default model). |
+| `metadata` | Optional named list. Custom metadata tags for the report header; URL-like values are made clickable in the report. Default is `NULL`. |
+| `n_parallel_clusters` | Integer. Number of parallel requests to the model (max 50). High values can trigger rate limits. Default is 2. |
+| `results_prefix` | Character. Prefix for the results added to `obj`. The factor columns `_annotation_`, `_cellOntologyTerm_`, `_cellOntologyTermID_`, `_cellState_` and `_reviewerConfidence_` are added to `obj@meta.data`, with three copies under older names: `_` (label), `_ontologyTerm_` and `_ontologyID_`. The job details are stored in `obj@misc[[paste0(prefix, "_jobDetails")]]` and the results as described under Value. Default is `"cytetype"`. |
+| `poll_interval_seconds` | Integer. How often (seconds) to poll the API for results. Default from options (10). |
+| `timeout_seconds` | Integer. Maximum time (seconds) to wait for results. When it is reached, `CyteTypeR()` stops with an error of class `cytetype_timeout_error` that names the job ID; the job keeps running on the server and [`GetResults()`](#r-api-getresults) retrieves its results later. Default from options (7200). |
+| `api_url` | Optional character. CyteType API server origin. If `NULL`, uses `CYTETYPE_API_URL`, the configured R option, or `https://cytetype.nygen.io`. |
+| `auth_token` | Optional character. Bearer token for API authentication. If `NULL`, uses credentials saved by [`SetupCyteTypeR()`](#r-api-setupcytetyper) or [`LoginCyteTypeR()`](#r-api-logincytetyper). |
+| `save_query` | Logical. Whether to save the request payload to a JSON file. Default is `TRUE`. Set this to `FALSE` when `llm_configs` contains provider credentials because the saved payload includes the complete LLM configuration. |
+| `query_filename` | Character. Filename for the saved query when `save_query` is `TRUE`. Default is `"query.json"`. |
+| `upload_timeout_seconds` | Integer. Socket read timeout (seconds) for each artifact upload. Default is 3600. |
+| `upload_max_workers` | Integer. Maximum number of parallel upload workers. Default is 6. |
+| `require_artifacts` | Logical. If `TRUE`, an error during artifact build or upload stops the run; if `FALSE`, failures are skipped and annotation continues without artifacts. Default is `TRUE`. |
+| `show_progress` | Logical. Whether to show progress (spinner and cluster status). Set `FALSE` to disable. Default is `TRUE`. |
+| `override_existing_results` | Logical. If `TRUE`, allow overwriting existing results with the same `results_prefix`. If `FALSE` and results exist, an error is raised. Default is `FALSE`. |
+| `visibility` | Optional character. Who can view the report: `"private"`, `"org"` (your organization; needs an organization API key) or `"public"` (anyone with the link). Default is `NULL`, which uses the server default (`"private"`). With `"org"` and an API key that looks like a personal key (it starts with `cyt_p_`), a warning is logged before the files are uploaded and the run continues; the server checks the key when the job is submitted and rejects personal keys. |
+
+**Value**
+
+The Seurat object `obj` with the results added. Assign it, for example `obj <- CyteTypeR(obj, prepped_data)`; the object you pass in is not changed.
+
+- `obj@meta.data` gains the factor columns listed under `results_prefix`. Cells in clusters without a result get `"Unknown"` (`""` for the cell state) in the first five columns and `NA` in the three older ones. The reviewer confidence column holds `"High"`, `"Moderate"`, `"Low"`, or `"Unknown"` when it is missing; its levels follow that order, and only the levels that occur are kept.
+- `obj@misc[[paste0(prefix, "_jobDetails")]]` holds `job_id`, `report_url`, `api_url`, `group_key`, `cluster_labels` and, when set, `visibility`.
+- With the default prefix, `obj@misc$cytetype_results` is a data.frame with one row per annotated cluster and the columns `clusterId` (your cluster label), `annotation`, `ontologyTerm`, `ontologyTermID`, `granularAnnotation`, `cellState`, `justification`, `supportingMarkers`, `conflictingMarkers`, `missingExpression`, `unexpectedExpression`, `reviewerConfidence`, `isHeterogeneous` and `heterogeneityType`. Its attributes `"job_id"` and `"result"` hold the job ID and the full results in the form returned by [`GetResults()`](#r-api-getresults).
+- With another prefix, `obj@misc[[paste0(prefix, "_results")]]` is a list with `job_id` and `result` (the full results), and `obj@misc$cytetype_results` is left as it is.
+
+**Details**
+
+The function performs the following workflow:
+
+1. Constructs the analysis query from prepared data
+2. Optionally saves the query to a JSON file
+3. Uploads the artifact files and submits the job
+4. Polls for job completion with progress updates
+5. Retrieves results and integrates annotations into the Seurat object
+
+The evidence columns of the results table (`justification`, `supportingMarkers`, `conflictingMarkers`, `missingExpression` and `unexpectedExpression`) are filled from the structured output of current jobs when the stored fields are empty. `supportingMarkers` and `conflictingMarkers` list genes separated by `"; "`.
+
+While waiting, network errors are retried until `timeout_seconds` is reached. Other errors while checking the job stop the run after five in a row. When the results of a completed job cannot be fetched, the rate limit and server errors (HTTP 500, 502, 503 and 504) are retried in the same way, and other errors, such as a deleted report (HTTP 410), stop the run at once. These errors signal a [`cytetype_api_error()`](#r-api-cytetype-api-error) condition. If the run stops or is interrupted, the job keeps running on the server: retrieve its results with `GetResults(job_id = "")`, using the job ID printed when the job was submitted.
+
+**Examples**
+
+```r
+# Basic usage with prepared data
+annotated_seurat <- CyteTypeR(
+  obj = seurat_obj,
+  prepped_data = prepped_data,
+  study_context = "Single-cell analysis of human brain tissue"
+)
+
+# With custom settings and metadata
+annotated_seurat <- CyteTypeR(
+  obj = seurat_obj,
+  prepped_data = prepped_data,
+  study_context = "Alzheimer's disease brain samples",
+  metadata = list(
+    "DOI" = "10.1038/example",
+    "GEO_Accession" = "GSE123456"
+  ),
+  timeout_seconds = 1800,
+  poll_interval_seconds = 60,
+  visibility = "public"
+)
+```
+
+**See also**
+
+[`PrepareCyteTypeR()`](#r-api-preparecytetyper) for data preparation, [`GetResults()`](#r-api-getresults) for manual result retrieval
+
+(r-api-cleanupartifacts)=
+### `CleanUpArtifacts()`
+
+*Clean Up Artifacts*
+
+Deletes the artifact files (`vars.h5` and `obs.duckdb`) that [`PrepareCyteTypeR()`](#r-api-preparecytetyper) wrote, once [`CyteTypeR()`](#r-api-cytetyper) has uploaded them.
+
+**Usage**
+
+```r
+CleanUpArtifacts(prepped_data)
+```
+
+**Arguments**
+
+| Argument | Description |
+| --- | --- |
+| `prepped_data` | Named list containing prepared data from `PrepareCyteTypeR()`. |
+
+**Value**
+
+Invisibly, `TRUE` when the artifact files had been built and are now removed, and `FALSE` when they were never built (nothing is deleted).
+
+**Examples**
+
+```r
+prepped_data <- PrepareCyteTypeR(pbmc, pbmc.markers)
+pbmc <- CyteTypeR(pbmc, prepped_data, study_context = "Human PBMCs")
+CleanUpArtifacts(prepped_data)
+```
+
+(r-api-results)=
+## Results
+
+Read the results stored in a Seurat object, or fetch the results of a job by its ID.
+
+(r-api-getresults)=
+### `GetResults()`
+
+*Retrieve CyteType Analysis Results*
+
+If `obj` and `results_prefix` are given, tries to load from `obj@misc` first. If no local result exists but job details are available, fetches the result from the API. If only `job_id` is given, fetches the result from the API.
+
+**Usage**
+
+```r
+GetResults(
+  obj = NULL,
+  job_id = NULL,
+  results_prefix = "cytetype",
+  auth_token = NULL,
+  api_url = NULL,
+  save_json = TRUE
+)
+```
+
+**Arguments**
+
+| Argument | Description |
+| --- | --- |
+| `obj` | Seurat object that may contain stored results (optional). |
+| `job_id` | Job ID from submission (optional if `obj` + `results_prefix` have stored job details). |
+| `results_prefix` | Prefix used when storing results. Default `"cytetype"`. |
+| `auth_token` | Optional bearer token for API fetch. If `NULL`, uses saved CyteType credentials. When retrieving a job stored in `obj`, an explicit token also requires a matching `api_url`. |
+| `api_url` | Optional CyteType server origin for standalone or legacy job retrieval. When `NULL`, uses `CYTETYPE_API_URL` or `https://cytetype.nygen.io`. |
+| `save_json` | Logical. When the results are fetched by `job_id`, whether to save the server's response as `cytetypeR_results_.json` in the working directory. Default is `TRUE`. |
+
+**Value**
+
+Depends on where the results come from:
+
+- `GetResults(obj)` after a run with the default prefix: the data.frame in `obj@misc$cytetype_results` (see [`CyteTypeR()`](#r-api-cytetyper)).
+- Results stored under another prefix, or fetched for the job stored in `obj`: a list with `annotations` (one list per annotated cluster with `clusterId`, the CyteType cluster ID, `annotation`, `ontologyTerm`, `ontologyTermID`, `granularAnnotation`, `cellState`, `justification`, `supportingMarkers`, `conflictingMarkers`, `missingExpression`, `unexpectedExpression`, `reviewerConfidence`, `isHeterogeneous` and `heterogeneityType`), `raw_annotations` (the server's per-cluster results, unchanged) and the server's `studyContext`, `summary`, `semanticOrder`, `clusterCategories` and `displayLevel`.
+- `GetResults(job_id = ...)`: a data.frame with the same columns as `obj@misc$cytetype_results`, where `clusterId` is the CyteType cluster ID. Its attributes `"job_id"` and `"result"` hold the job ID and the list described above.
+- `NULL`, with a message, when the job has not finished yet or is not found.
+
+**Details**
+
+Fetching asks the server for the job's status and, when the job has completed, for its results: two requests, without waiting. A failed job stops with an error. When the status or the results cannot be fetched, `GetResults()` signals a [`cytetype_api_error()`](#r-api-cytetype-api-error) condition whose `error_code` holds the server's code when it sent one, such as `"REPORT_DELETED"`. Problems with the API key signal its subclass `cytetype_auth_error`.
+
+`reviewerConfidence` is `"High"`, `"Moderate"` or `"Low"` (`NULL` in the list and `NA` in the table when missing), `isHeterogeneous` is `TRUE` or `FALSE` (or missing), and `heterogeneityType` names the kind of heterogeneity the reviewer found. In the list, `supportingMarkers` and `conflictingMarkers` are lists of gene names.
+
+Fetched results are not added to `obj`; R does not modify the object you pass in.
+
+**Examples**
+
+```r
+# Results stored in the object by CyteTypeR()
+results <- GetResults(annotated_seurat)
+
+# Results of a job by its ID, without saving the JSON file
+results <- GetResults(job_id = "", save_json = FALSE)
+```
+
+**See also**
+
+[`CyteTypeR()`](#r-api-cytetyper) for job submission
+
+(r-api-sign-in)=
+## Sign-in and the cytetyper command
+
+Save an API key, open the dashboard or a job's report, and install the `cytetyper` terminal command. Each `cytetyper` command calls one of these functions (see the [Command-line reference](cli.md)).
+
+(r-api-setupcytetyper)=
+### `SetupCyteTypeR()`
+
+*Set up CyteType authentication*
+
+Validates matching saved credentials. When none are available, opens CyteType sign-in in a browser and stores the resulting personal API key.
+
+**Usage**
+
+```r
+SetupCyteTypeR(api_url = NULL, force = FALSE)
+```
+
+**Arguments**
+
+| Argument | Description |
+| --- | --- |
+| `api_url` | Optional CyteType server origin. When `NULL`, uses `CYTETYPE_API_URL` or `https://cytetype.nygen.io`. |
+| `force` | Whether to skip saved credential validation and authenticate with a new key. |
+
+**Value**
+
+The saved credential metadata, invisibly.
+
+**Details**
+
+The browser flow verifies the user's email with a one-time code and returns to a local callback server. CyteTypeR and the Python client use the same credentials file. For development, pass the development server origin as `api_url`. If a saved key is invalid or inactive, use `force = TRUE`.
+
+**Examples**
+
+```r
+SetupCyteTypeR()
+SetupCyteTypeR(force = TRUE)
+SetupCyteTypeR(api_url = "")
+```
+
+(r-api-logincytetyper)=
+### `LoginCyteTypeR()`
+
+*Save an existing CyteType API key*
+
+Validates an API key and stores it for later CyteTypeR requests.
+
+**Usage**
+
+```r
+LoginCyteTypeR(api_token = NULL, api_url = NULL)
+```
+
+**Arguments**
+
+| Argument | Description |
+| --- | --- |
+| `api_token` | Optional API key. When omitted, a hidden prompt is shown in an interactive session. Otherwise the key is read from the first line of standard input, so `cytetyper login` and scripts can pipe it in. Typed into a terminal, the key is hidden on Linux and macOS but shown on Windows. |
+| `api_url` | Optional CyteType server origin. When `NULL`, uses `CYTETYPE_API_URL` or `https://cytetype.nygen.io`. |
+
+**Value**
+
+The saved credential metadata, invisibly.
+
+**Examples**
+
+```r
+LoginCyteTypeR()
+LoginCyteTypeR(api_url = "")
+```
+
+(r-api-logoutcytetyper)=
+### `LogoutCyteTypeR()`
+
+Remove local CyteType credentials
+
+**Usage**
+
+```r
+LogoutCyteTypeR()
+```
+
+**Value**
+
+`TRUE` when credentials were removed, otherwise `FALSE`, invisibly.
+
+**Details**
+
+This removes only the local credentials file. Revoke an API key from the dashboard when it must also be invalidated on the server.
+
+**Examples**
+
+```r
+LogoutCyteTypeR()
+```
+
+(r-api-opencytetypedashboard)=
+### `OpenCyteTypeDashboard()`
+
+Open the CyteType dashboard
+
+**Usage**
+
+```r
+OpenCyteTypeDashboard()
+```
+
+**Value**
+
+The dashboard URL, invisibly.
+
+**Details**
+
+Uses the dashboard associated with saved credentials. When credentials are absent, opens the production dashboard at `https://cytetype.nygen.io/dashboard`.
+
+**Examples**
+
+```r
+OpenCyteTypeDashboard()
+```
+
+(r-api-viewcytetypejob)=
+### `ViewCyteTypeJob()`
+
+Open a CyteType job
+
+**Usage**
+
+```r
+ViewCyteTypeJob(job_id, api_url = NULL)
+```
+
+**Arguments**
+
+| Argument | Description |
+| --- | --- |
+| `job_id` | CyteType job identifier. |
+| `api_url` | Optional CyteType server origin. When `NULL`, uses the origin from saved credentials or the configured default. |
+
+**Value**
+
+The sign-in URL for the job, invisibly.
+
+**Examples**
+
+```r
+ViewCyteTypeJob("")
+ViewCyteTypeJob("", api_url = "")
+```
+
+(r-api-installcytetypercli)=
+### `InstallCyteTypeRCli()`
+
+Install the cytetyper terminal command
+
+**Usage**
+
+```r
+InstallCyteTypeRCli(destination = NULL, overwrite = FALSE)
+```
+
+**Arguments**
+
+| Argument | Description |
+| --- | --- |
+| `destination` | Directory in which to install the command. Defaults to the directory containing the active `Rscript` executable. The default is recommended because the launcher uses the adjacent `Rscript` executable. |
+| `overwrite` | Whether to replace an existing command. |
+
+**Value**
+
+The installed command path, invisibly.
+
+**Details**
+
+After installation, run `cytetyper setup` in a terminal to authenticate against `https://cytetype.nygen.io`. During development, use `cytetyper setup --api-url ""`.
+
+**Examples**
+
+```r
+InstallCyteTypeRCli()
+```
+
+(r-api-errors)=
+## Errors
+
+The condition that CyteTypeR signals when the status or the results of a job cannot be fetched (see [Errors and error codes](errors.md)).
+
+(r-api-cytetype-api-error)=
+### `cytetype_api_error()`
+
+*Custom API Error*
+
+Creates the condition that CyteTypeR signals when the status or the results of a job cannot be fetched: by [`GetResults()`](#r-api-getresults), and by [`CyteTypeR()`](#r-api-cytetyper) when it stops waiting for a job because of such errors. Errors about the API key are signalled as `cytetype_auth_error`, a subclass.
+
+**Usage**
+
+```r
+cytetype_api_error(message, call = NULL, error_code = NULL, status_code = NULL)
+```
+
+**Arguments**
+
+| Argument | Description |
+| --- | --- |
+| `message` | Error message. |
+| `call` | Where the error came from: `"network"` when the server could not be reached, `"api"` for other errors. Default is `NULL`. |
+| `error_code` | The error code the server sent, such as `"REPORT_DELETED"`, or `NULL` when there is none. Default is `NULL`. |
+| `status_code` | The HTTP status of the server's response, such as `503`, or `NULL` when there is none, for example when the server could not be reached. Default is `NULL`. |
+
+**Value**
+
+An error condition of class `cytetype_api_error`, with the elements `message`, `call`, `error_code` and `status_code`. Printing it shows `CyteType API Error: `.
+
+**Examples**
+
+```r
+error <- cytetype_api_error(
+  paste(
+    "Error while checking job status: HTTP 410 [REPORT_DELETED]",
+    "This report has been deleted by the owner"
+  ),
+  call = "api",
+  error_code = "REPORT_DELETED",
+  status_code = 410L
+)
+print(error)
+
+# Handle the condition by its class and read the server's code.
+tryCatch(
+  stop(error),
+  cytetype_api_error = function(e) e$error_code
+)
+```
+
diff --git a/docs/source/results/interpreting-results.md b/docs/source/results/interpreting-results.md
new file mode 100644
index 0000000..57f16d1
--- /dev/null
+++ b/docs/source/results/interpreting-results.md
@@ -0,0 +1,244 @@
+---
+description: How to read CyteType labels, reviewer confidence, heterogeneity flags and classifications, what the results cannot tell you, what to record for reproducibility, and a checklist for reviewing a run.
+---
+
+(interpreting-results)=
+# Interpret and review results
+
+CyteType proposes a label for each cluster, together with the evidence for it and an independent review. Treat the labels as reviewed proposals: they are most useful when you check the evidence before you rely on them. This page explains each signal, what CyteType cannot tell you, and how to review a run. [How CyteType works](../getting-started/how-cytetype-works.md) describes the pipeline that produces them.
+
+(interpret-label)=
+## What a label is
+
+- **One label per cluster.** CyteType annotates clusters, not cells. Every cell in a cluster gets the cluster's label, so a small population inside a larger cluster is not labeled on its own.
+- **Identity, state and description are separate.** The label names the cell's identity. The cell state, such as proliferating or activated, is a separate field. The granular annotation is a one-sentence description that combines the two with key genes.
+- **The label is chosen, then reviewed.** CyteType weighs competing candidate identities over several rounds and places the winner in its reference hierarchy of cell types, which is linked to the Cell Ontology. Once all clusters are annotated, it compares similar clusters and harmonizes their labels. An independent reviewer then assesses each final label.
+- **The Cell Ontology term can be broader than the label.** The term is matched to the identity before label refinement, which can add a qualifier to tell apart clusters that share an identity. The qualifier is not part of the term.
+
+(interpret-levels)=
+## Identity, levels and state
+
+The reference hierarchy runs from L1, the broadest level (a lineage), to L5, the most specific. The report shows the path as the **Annotation hierarchy** in Justification, the CSV export has L1 to L4, and the raw results have all five levels (see [Raw results](results-in-your-data.md#results-raw)). Not every cluster has every level: placement stops where the evidence stops, and some branches of the hierarchy are shallower than others.
+
+### Why not deeper?
+
+When CyteType stops at a level, the report explains why under **Why this level was retained**, in the Selection Rounds of the Justification section. The usual reasons:
+
+| Reason given in the report (shortened) | What it means for you |
+|---|---|
+| More specific descendants were investigated, but their evidence was not strong enough. | The data does not separate the subtypes. More informative markers, a finer clustering or a more specific study context can help. |
+| The identity is already the most specific one in the reference hierarchy. | There is nothing narrower to test. |
+| The evidence consistently supported this identity, and no narrower distinction was needed. | A confident stop. |
+| The evidence did not support a narrower label reliably within the permitted rounds. | Treat the depth of the label as uncertain. |
+| The identity did not map reliably to a reference identity. | It was kept outside the hierarchy (see [Classification](#interpret-classification)). |
+| The evidence supported a combined identity, but its components share no reliable deeper reference category. | The cluster appears to contain two identities; check its heterogeneity and consider subclustering. |
+
+### Cell state
+
+The cell state describes what the cells are doing rather than what they are, for example proliferation, exhaustion or senescence. It is supported by the gene sets under Cell State Evidence in the report. An unexpected state, such as proliferation in a cell type that is usually quiescent in your tissue, deserves a look: it can be real biology or a sign that the cluster mixes populations.
+
+### Display groups
+
+The report's Biological Order and the CSV's `coarse_cell_type` group clusters at one level of the hierarchy, chosen for each study so that there is a manageable number of groups. Unresolved clusters form a group of their own, and each identity outside the hierarchy is listed under its own label. Use the groups for orientation; a cluster's own label and levels are what count.
+
+(interpret-classification)=
+## Classification
+
+Each label has a classification that relates it to the reference hierarchy. It is in the raw results as `classification` (see [Raw results](results-in-your-data.md#results-raw)), and the report shows a chip in the Justification header for some values. Current jobs use three values:
+
+| Value | In the report | Meaning | What to do |
+|---|---|---|---|
+| `catalogue` | No chip | The identity was placed on an entry of the reference hierarchy. | Review the evidence as usual. |
+| `outOfCatalog` | No chip; the hierarchy shows only the identity | A coherent identity with no matching entry in the hierarchy. The Justification section names the broader biological group it was placed in. | Read the evidence closely and confirm the identity with known markers or the literature. Make sure it is not an artifact, such as a population of doublets. |
+| `unresolved` | **Unresolved** | No identity could be assigned, and the label is `Unknown`. The reviewer does not run, so the confidence is Low. | Check the cluster's quality (counts, mitochondrial fraction, doublets) and its marker genes, and consider merging, splitting or removing it. |
+
+Reports of older jobs can also show these values:
+
+| Value | Chip | Meaning | What to do |
+|---|---|---|---|
+| `broaderParent` | **Broader parent** | Only a broader entry of the hierarchy is supported. | Treat the label as a parent-level call, and subcluster if you need more detail. |
+| `mixedDoublet` | **Mixed / doublet** | Signals of more than one cell type, such as doublets or a mixed population. | Check for doublets and subcluster (see [Heterogeneity](#interpret-heterogeneity)). |
+| `malignantAberrant` | **Malignant / aberrant** | Malignant or otherwise aberrant cells. | Confirm with independent evidence, such as copy-number inference or clinical annotations. |
+
+In current jobs, a mixed population shows up as a heterogeneity flag, or as a combined identity in the Justification section.
+
+(interpret-confidence)=
+## Reviewer confidence
+
+After annotation, an independent reviewer assesses each final label. A panel of expert perspectives generated by CyteType's models examines the marker evidence. The reviewer weighs their agreement together with the cell classifier and the genes that set the cluster apart from its neighbors, and assigns one of three levels with a short rationale:
+
+| Level | The reviewer's criterion |
+|---|---|
+| High | Strong agreement across the panel, with compelling evidence. |
+| Moderate | Mixed assessments or notable concerns. |
+| Low | Fundamental problems identified by most of the panel. |
+
+Read the confidence as a categorical triage signal:
+
+- **It is not a calibrated probability.** High does not mean that the label is correct with a known probability, and the three levels are not evenly spaced. Use them to decide where to look first, not as an acceptance threshold.
+- **It is not any of the other numbers in the report.** The Review confidence and Reference match confidence percentages in the Justification section rank candidates inside the annotation step. The Match score compares CyteType's label with yours. The cell classifier's accuracy measures how separable the cluster is. None of them is the reviewer's confidence.
+- **Low has more than one cause.** The reviewer may have found real problems. The cluster may also be unresolved, in which case no review runs, or the review may have failed. In those two cases the Quality Control section shows "Confidence details not available." instead of a rationale. Read the rationale before you act on a Low.
+
+`reviewerConfidence` is missing (`None` in Python, `NA` in the R results table) when a stored result has no confidence at all, for example results saved by cytetype 0.7.0 and older. The cell-level confidence column shows `Unknown` for cells whose cluster has no confidence, including clusters without a result.
+
+What to do with each level:
+
+- **High:** spot-check the supporting genes and the heterogeneity call.
+- **Moderate:** read the rationale and the panel's weaknesses and alternatives, and look at the missing and unexpected genes.
+- **Low:** treat the label as unconfirmed. Check whether the review ran, then the marker genes, the heterogeneity and the cluster's quality. A finer or cleaner clustering, or a more specific study context, followed by a new run often helps.
+
+To list the clusters that need a closer look:
+
+::::{tab-set}
+:::{tab-item} Python
+:sync: python
+```python
+import pandas as pd
+
+table = pd.DataFrame(annotator.get_results()["annotations"])
+needs_review = table[(table["reviewerConfidence"] != "High") | table["isHeterogeneous"].eq(True)]
+needs_review[["clusterId", "annotation", "reviewerConfidence", "isHeterogeneous", "heterogeneityType"]]
+```
+:::
+:::{tab-item} R
+:sync: r
+```r
+res <- obj@misc$cytetype_results
+needs_review <- subset(res, !(reviewerConfidence %in% "High") | isHeterogeneous %in% TRUE)
+needs_review[, c("clusterId", "annotation", "reviewerConfidence", "isHeterogeneous", "heterogeneityType")]
+```
+:::
+::::
+
+(interpret-heterogeneity)=
+## Heterogeneity
+
+The reviewer also judges whether the cluster is one population (**Homogeneous**) or contains distinct populations (**Heterogeneous**). It weighs the panel's assessments, conflicts between lineage markers, the cell classifier's breakdown of the cluster's cells and the differences from neighboring clusters. Variation in state within one cell type, such as activation or maturation, does not count as heterogeneity.
+
+When a cluster is heterogeneous and cell-level data was uploaded with the job, a second step classifies the type of heterogeneity from how pairs of conflicting genes are co-expressed in single cells (the Co-expression Evidence in the report):
+
+| Type | Meaning | What to do |
+|---|---|---|
+| `distinct_populations` | Two or more identities in separate subsets of cells. | Subcluster and annotate the subclusters. |
+| `mixed_lineage` | Markers of mutually exclusive lineages occur together in the same cells, in a way that doublets do not explain. | Check doublet scores, then consider an aberrant or malignant program and validate it independently. |
+| `doublet_artifact` | Conflicting markers occur together in the same cells in a pattern that suggests two cells captured in one droplet. | Run doublet detection, remove the doublets and recluster. |
+| `transitional_state` | Cells form a continuum along a developmental or activation axis. | Consider a trajectory or pseudotime analysis rather than splitting the cluster. |
+| `unresolved` | The co-expression evidence is too weak to choose a type. | Inspect the Co-expression Evidence, and subcluster to test whether a split holds. |
+
+The type is missing (`None` in Python, `NA` in R) for homogeneous clusters, and also when it could not be classified, for example because the job ran without uploaded cell-level data (`require_artifacts=False`). The report shows the heterogeneity call and its reasoning but not the type: both clients return it as `heterogeneityType`, and the raw results also hold the reasoning for it.
+
+To subcluster a heterogeneous cluster, for example cluster `5`:
+
+::::{tab-set}
+:::{tab-item} Python
+:sync: python
+```python
+import scanpy as sc
+
+sc.tl.leiden(
+    adata,
+    restrict_to=("leiden", ["5"]),
+    resolution=0.5,
+    flavor="igraph",
+    n_iterations=2,
+    key_added="leiden_sub",
+)
+```
+:::
+:::{tab-item} R
+:sync: r
+```r
+library(Seurat)
+
+# Splits cluster "5" of the active identities using the shared nearest-neighbor graph
+obj <- FindSubCluster(obj, cluster = "5", graph.name = "RNA_snn",
+                      subcluster.name = "sub_cluster", resolution = 0.5)
+```
+:::
+::::
+
+Then compute marker genes for the new column and run CyteType on it with a new `results_prefix` (see [Prepare your data](../guides/prepare-data.md) and [Run, monitor and rerun jobs](../guides/running-jobs.md)). For doublets, use a dedicated detector such as Scrublet (`sc.pp.scrublet` in Scanpy) or scDblFinder in R.
+
+(interpret-compare)=
+## Compare with your existing labels
+
+When the labels in your cluster column are names rather than numbers, the reviewer scores how similar CyteType's label is to yours, from 0 to 100. The score appears as **Match** in the report, as `author_label_similarity_score` in the CSV export and as `labelComparisonScore` in the raw results.
+
+- **How it is calculated.** The score comes from CyteOnto, an ontology-based measure of the semantic similarity between cell type labels. When CyteOnto returns no score, the reviewer estimates one instead (`labelComparisonLlmScore` in the raw results) from the relationship between the two labels: 100 for the same type, 95 for synonyms, 70 when one is a broader or narrower version of the other, 40 for sibling types, 20 for distant relatives in the same lineage and 0 for different lineages, minus a penalty when the labels differ in state, such as activation or differentiation.
+- **Bands.** The report's filter groups scores into High (80 or more), Moderate (40 to 79) and Low (below 40).
+- **When there is no score.** Labels that are cluster numbers or generic names, such as `0`, `C1`, `cluster 3`, `unknown` or `NA`, are not compared.
+
+How to read it:
+
+- A high score means that the two labels agree, not that either is correct.
+- A low score marks a disagreement worth reading, not an error. Many disagreements are about granularity (your `T cell` against CyteType's `CD8+ T Cell`), naming conventions or state. Read both labels and the evidence before you decide.
+- In the report, switch **Labels** to **User Label** and use the **Match** filter to review the low matches first (see [Compare with your own labels](report.md#report-compare)).
+
+(interpret-limits)=
+## Known limits
+
+- **Cluster-level only.** Every cell in a cluster gets the same label, so the results depend on your clustering. A cluster that merges two cell types gets one label (often flagged as heterogeneous), and a cell type split across clusters gets several labels, which may differ.
+- **Not deterministic.** Rerunning the same data can change the wording, the reasoning, the confidence and sometimes the label. Compare runs by their biology rather than by exact strings, and keep the job ID of the run you report.
+- **Gene expression only.** CyteType uses the expression matrix (in R, the RNA assay), the marker genes, the cell metadata and your study context. Other modalities in your object, such as surface protein or chromatin accessibility, are not used.
+- **Human and mouse.** CyteType is validated on human and mouse data. Results for other species have not been validated.
+- **Gene symbols.** CyteType reasons about gene symbols, so data indexed by Ensembl or other IDs needs a symbol column (see [Prepare your data](../guides/prepare-data.md)).
+- **Input quality matters.** Weak or contaminated marker genes, poorly normalized data, or a vague or wrong study context lead to weaker labels.
+- **Model-generated review.** The reviewer and its panel are generated by CyteType's models. They make the evidence easier to check; they do not replace review by a scientist who knows the tissue.
+- **No detection of personal data.** CyteType does not detect or remove identifiers or protected health information. Your cell metadata is uploaded with the job, so remove identifying information before you run it (see [Security and privacy](../support/security-and-privacy.md)).
+- **Display sampling.** The report's embedding shows up to `max_cells_per_group` cells per cluster (1,000 by default). This affects only the display.
+
+(interpret-reproducibility)=
+## Reproducibility: what to record
+
+The annotation runs on CyteType's servers, and the server workflow and hosted models can change between runs. Neither the results nor the report record a server release, so record enough to identify each run:
+
+- **The job ID.** It identifies the run and its report.
+- **The client and its version**, for example `cytetype` 0.21.0 or `CyteTypeR` 0.6.0. The report also shows it as **Client** under Study Information.
+- **The date of the run.**
+- **`query.json`.** `run()` and `CyteTypeR()` write it to the working directory by default (`save_query` and `query_filename`). It holds what was submitted: the study context, metadata tags, cluster labels, marker genes, cluster metadata summaries, expression summaries and the client version. The Python client never writes LLM API keys to it. CyteTypeR writes the complete request, including your `llm_configs` and their API keys, so pass `save_query = FALSE` when you use your own LLM keys in R, or remove the keys before you archive the file.
+- **Your LLM configuration**, if you used your own models: the provider and model names.
+
+::::{tab-set}
+:::{tab-item} Python
+:sync: python
+```python
+import cytetype
+
+details = adata.uns["cytetype_jobDetails"]
+print(details["job_id"], details["report_url"], cytetype.__version__)
+```
+:::
+:::{tab-item} R
+:sync: r
+```r
+details <- obj@misc$cytetype_jobDetails
+cat(details$job_id, details$report_url, as.character(packageVersion("CyteTypeR")), "\n")
+```
+:::
+::::
+
+To cite CyteType and report the version you used, see [Cite CyteType and report versions](../support/citing.md).
+
+(interpret-checklist)=
+## Review checklist
+
+Work through the report with this list before you use the labels:
+
+1. **Completeness.** The job finished and every cluster has a label. Note any Unresolved clusters.
+2. **Overview.** In Biological Order, the display groups and their sizes make sense for your tissue. An unexpected lineage, such as hepatocytes in blood, points to contamination, ambient RNA or a wrong study context.
+3. **Low and Moderate confidence.** Filter the cluster list to them. For each, read the Quality Control rationale; if there is none, the review did not run or failed.
+4. **Heterogeneity.** For each heterogeneous cluster, read the reasoning and the type, and decide whether to subcluster or remove doublets.
+5. **Marker evidence.** The supporting genes are ones you would expect for the label, and the missing and unexpected genes have a plausible explanation. Color the embedding by one or two key genes to check.
+6. **Depth.** Where a label is broader than you need, read **Why this level was retained** before you assign a finer type yourself.
+7. **Outside the hierarchy.** Identities outside the reference hierarchy get extra scrutiny.
+8. **Cell states.** The states are plausible for your tissue and condition.
+9. **Your labels.** If you compared CyteType with existing labels, read every Low match.
+10. **Record and export.** Save the job ID, client version, date and `query.json`, then download the CSV or save your object.
+
+If you change the clustering, the marker genes or the study context, run CyteType again and compare the runs (see [Run, monitor and rerun jobs](../guides/running-jobs.md)).
+
+## See also
+
+- [The CyteType report](report.md): where each signal appears.
+- [Results in AnnData and Seurat](results-in-your-data.md): the same signals in your object.
+- [How CyteType works](../getting-started/how-cytetype-works.md): the annotation pipeline.
diff --git a/docs/source/results/report.md b/docs/source/results/report.md
new file mode 100644
index 0000000..02a4c33
--- /dev/null
+++ b/docs/source/results/report.md
@@ -0,0 +1,274 @@
+---
+description: A guide to the interactive CyteType report, from opening it and following a running job to reading every evidence section, comparing with your own labels, sharing and exporting a CSV.
+---
+
+(report)=
+# The CyteType report
+
+Every job has an interactive report at `https://cytetype.nygen.io/report/`. It shows your embedding, the label for each cluster and the evidence behind it: the candidates CyteType considered, the marker genes, the reviewer's assessment and how the label fits the study. The labels are also copied into your object (see [Results in AnnData and Seurat](results-in-your-data.md)); the report is where you check them.
+
+(report-open)=
+## Open the report
+
+The client prints the report link as soon as the job starts, so you can follow the job before it finishes. The link is also stored with the results.
+
+::::{tab-set}
+:::{tab-item} Python
+:sync: python
+```python
+adata.uns["cytetype_jobDetails"]["report_url"]
+```
+
+To open a report by its job ID from a terminal:
+
+```bash
+cytetype view 
+```
+:::
+:::{tab-item} R
+:sync: r
+```r
+obj@misc$cytetype_jobDetails$report_url
+
+ViewCyteTypeJob("")
+```
+
+If you installed the command-line launcher with `InstallCyteTypeRCli()`, `cytetyper view ` does the same from a terminal.
+:::
+::::
+
+`cytetype view` and `ViewCyteTypeJob()` open the report through the sign-in page of the server you signed in to; if your browser is already signed in, you go straight to the report (see the [command-line reference](../reference/cli.md)). You can also open any of your reports from the Reports list in the [dashboard](../platform/dashboard.md) with **View**.
+
+(report-access)=
+## Who can open a report
+
+The report uses your browser sign-in (the emailed one-time code at `https://cytetype.nygen.io/login`), not your API key. Who can open it depends on the job's visibility:
+
+| Visibility | Who can open the report |
+|---|---|
+| Private (the default) | You, and the people you shared it with by email. |
+| Organization | Members of your organization's license, and the people you shared it with. |
+| Public | Anyone with the link, without signing in. Any signed-in user can also find it in Chat's report search. |
+
+If you are not signed in, the report shows **Authentication Required** with a **Sign In to View Report** button, and after signing in you return to the report. An account without access sees the same screen even when it is signed in: ask the owner to share the report with that email address. A deleted report shows **Report Deleted**, and a job ID that does not exist shows **Error Loading Report**. [Visibility and sharing](../platform/sharing.md) explains how to change the visibility and manage shares, and what a public report exposes.
+
+(report-live)=
+## Follow a running job
+
+The report opens while the job is still running and updates itself, so you don't need to reload the page.
+
+- With no cluster selected, **Study Information** shows a progress tracker with the job's steps: Preparing, Understanding context, Annotating clusters, Refining annotations, Reviewing and Finalizing. During Annotating clusters and Reviewing it also counts how many clusters have passed that step.
+- In the cluster list, a spinner marks each cluster while the job runs (a clock before it starts). Selecting one shows **Annotation Pending** or **Annotation in Progress** with its current step, for example Annotating or Refining.
+- The embedding is available from the start. The labels and the evidence appear when the job reaches its final step.
+- Gene search becomes available once the report's gene index is ready; until then the search box reads "Loading gene index…".
+
+(report-layout)=
+## Page layout
+
+The report has three columns:
+
+- **Left sidebar:** the cluster list, with sorting, filters, a label switch and search.
+- **Center:** gene search, the embedding, and a card for the selected cluster (or the study information).
+- **Right:** a toolbar (Share, Open Chat, Download as CSV) above the evidence for the selected cluster, or the study summary when no cluster is selected.
+
+Select a cluster in the list or click it on the embedding. The address bar then includes `?cluster=`, so you can bookmark or send a link to a specific cluster, and the browser's back and forward buttons move between the clusters you viewed. The up and down arrow keys move to the previous or next cluster in the list.
+
+:::{note}
+Reports of jobs run with earlier CyteType versions can show a tabbed panel (Markers, Justification, Review, Clinical Info, Cluster Context) instead of the sections described on this page.
+:::
+
+(report-sidebar)=
+## Left sidebar
+
+The toolbar at the top of the sidebar has four controls:
+
+| Control | Options |
+|---|---|
+| Sort | **Biological Order** groups clusters under display-group headers. **Alphabetical** sorts by label. **Cluster ID** sorts by CyteType's cluster ID. |
+| Filter | **Confidence**: High, Moderate, Low. **Match**: High (β‰₯80%), Moderate (40-79%), Low (<40%), No Original Label. |
+| Labels | **CyteType Label** or **User Label** (the labels in your cluster column). |
+| Search clusters... | Filters the list by cluster ID or label as you type. Esc clears it. |
+
+Each row shows CyteType's cluster ID, the label and three dots. The dots show the reviewer's confidence, or the Match score when you display user labels; hover over them to see the value. Clusters that are still running are always listed. Clusters without a confidence value are hidden as soon as you untick any of the confidence boxes.
+
+In Biological Order, each header names a display group and counts its clusters. CyteType picks the level of its reference hierarchy for these groups so that the study has a manageable number of them, puts unresolved clusters in a group of their own, and lists each identity outside the hierarchy under its own label. The headers appear once the job has finished. While a cluster is selected, hover over a header to highlight its group on the embedding.
+
+**Dataset Overview**, below the toolbar, deselects the cluster and shows the [study summary](#report-summary).
+
+The footer links to your reports in the **Dashboard**, the **Citation** (the CyteType preprint), a light and dark theme switch, and the CyteType repository on GitHub.
+
+(report-center)=
+## Center column
+
+(report-gene-search)=
+### Gene search
+
+Type a gene symbol in the search box ("Search gene expression"), then pick a suggestion or press Enter. The embedding colors each cluster by the percentage of its cells that express the gene, on a scale from 0% to 100%; hover over a cluster to see its value in the legend. Only the genes in your data can be found. Click the gene name in the legend for a short description of the gene from MyGene.info (the top match, not filtered by species), and click Γ— to clear the coloring.
+
+Gene names in the right panel work the same way: click a gene to color the embedding by it, and click it again to clear.
+
+### Embedding
+
+The embedding shows the 2D coordinates sent with the job (UMAP by default) for a sample of up to 1,000 cells per cluster (`max_cells_per_group`), and at most 50,000 points in all. This sampling affects only the display. The embedding cannot be zoomed or panned. Hover over a cluster to see its label, and click it to select it. When cluster shapes are available, a button below the embedding cycles the view between Scatter (cells only), Hybrid (cells over the cluster shapes) and Hull (shapes only), and another one shows abbreviated cluster labels, which you can drag into place; **Reset label positions** puts them back. The shapes are created at the end of the job, so reload a report that you opened while the job was running. If the job had no coordinates, the embedding area reads "UMAP Visualization Not Available".
+
+(report-cluster-card)=
+### Cluster card
+
+When a cluster is selected, a card below the embedding summarizes it. Click the title, the subtitle, the cell state or the confidence to jump to the matching section of the right panel.
+
+| Field | Content |
+|---|---|
+| Title | The CyteType label. Opens Justification. |
+| Subtitle | The granular annotation, a one-sentence description of the cluster. Opens Cell Type Evidence. |
+| Cell State | The cell state, if one was called. Opens Cell State Evidence. |
+| Confidence | The reviewer's confidence, with a **Heterogeneous** badge when the reviewer found distinct populations in the cluster. Opens Quality Control. |
+| Ontology | The Cell Ontology ID and term name, linked to the term in the EBI Ontology Lookup Service. |
+| User Label | Your label for the cluster, with its **Match** score when there is one. |
+
+### Study information
+
+With no cluster selected, the card shows **Study Information**: the job **Status** (with the progress tracker while the job runs), a **Completed** row with the time the job was submitted, the **User-Provided Metadata** you passed as `metadata` (values that are URLs become links), and the **Client** that submitted the job with its version, for example `anndata v0.21.0` or `seurat v0.6.0`. [Study context and metadata](../guides/study-context.md) explains which metadata to pass.
+
+(report-toolbar)=
+## Toolbar
+
+The toolbar sits above the right panel.
+
+| Button | What it does |
+|---|---|
+| Share | Shown only to the report's owner. Invites one person by email; they get view access. **Manage all shares in dashboard** opens your reports in the dashboard. |
+| Open Chat | Opens [Chat](../platform/chat.md) in a new tab with this run attached, and with the selected cluster if there is one. Chat needs you to be signed in; the button reads **Sign in required** when you are not. |
+| Download as CSV | Downloads the cluster table described in [Download as CSV](#report-csv). |
+
+People you share a report with by email can open it after they sign in with that address, including people who don't have an account yet. See [Visibility and sharing](../platform/sharing.md).
+
+(report-evidence)=
+## Right panel: the evidence for a cluster
+
+Select a cluster to see its evidence in up to five sections: Justification, Cell Type Evidence, Cell State Evidence, Quality Control and Context Fit. A navigation bar at the top jumps to each section and adds a small summary: the number of genes for Cell Type Evidence, the number of gene sets for Cell State Evidence, and a colored dot for the confidence in Quality Control. Sections, and most items inside them, collapse and expand when you click their header.
+
+Gene chips are colored by how often the gene is detected in this cluster compared with the average of the other clusters: red when it is detected more often, blue when less often. Hover over a chip to see the log2 ratio and the percentage of the cluster's cells that express the gene.
+
+(report-justification)=
+### Justification
+
+How CyteType arrived at the label.
+
+- **Classification chip.** The header shows **Unresolved** when no identity could be assigned. Reports of older jobs can also show **Broader parent**, **Mixed / doublet** or **Malignant / aberrant**. [Interpret and review results](interpreting-results.md#interpret-classification) explains each.
+- **Annotation hierarchy.** The label's place in CyteType's reference hierarchy of cell types, as a path that starts at L1 (broadest). For an identity outside the hierarchy it shows only the identity. When the label combines two identities, each has its own row.
+- **Summary.** The conclusion for the label. Python returns the same text as `justification`.
+- **Candidates Investigated.** The identities that were considered and rejected, marked with Γ—. Each shows **Evidence against** and **Why considered**, then **Evidence details** and **Miscellaneous**.
+- **Selection Rounds.** The identity selected in each round, marked with a **Round** chip and βœ“. CyteType evaluates candidates in rounds and moves to more specific identities while the evidence supports them. Each round shows **Evidence supporting**, then either **How the annotation was refined** (a more specific identity went on to the next round) or **Why this level was retained** (why the annotation stopped at this level), followed by the details.
+- **Label Refinement.** When several clusters share an identity, CyteType compares them and can add a qualifier that tells them apart. This item shows the **Distinguishing feature** and the **Reasoning**; the CSV export has the qualifier in its own column.
+- **Biological order group.** For an identity outside the hierarchy, the broader biological group it was placed in and why.
+
+The **Evidence details** of a candidate can include:
+
+| Item | Content |
+|---|---|
+| Evidence supporting, Evidence against, Why considered | The arguments for and against the identity, and why it was proposed. |
+| Evidence synthesis | A balanced assessment of the supporting, missing and unexpected genes. |
+| Pathways | Pathways that support the identity. |
+| Gene evidence | Genes listed as **Evidence supporting**, **Expected but weak or absent** and **Unexpected expression**. |
+| Reference | The **Reference hierarchy** path and the **Reference match** reasoning for placing the identity in the hierarchy. |
+| Population consistency | Whether the evidence points to one population or to two distinct identities. |
+
+**Miscellaneous** holds **Review confidence** and **Reference match confidence** percentages, the **Reference node identifier** and the **Reference ranking rationale**. A candidate with a **combined identity** chip merges two identities, for example when the cluster appears to contain both; its **Merged identity** item lists them.
+
+:::{important}
+The percentages under Miscellaneous rank candidates inside the annotation step. They are not the reviewer's confidence shown in Quality Control, and they are not probabilities that the label is correct.
+:::
+
+Reports of older jobs show **Other candidates investigated** instead of the rounds, with Γ—Γ—Γ—, Γ—Γ— or Γ— for a strong, moderate or weak rejection, a **Consideration reason** and a **Rejection reason**, and the **Initial candidates** under **Supplementary information**.
+
+(report-cell-type-evidence)=
+### Cell Type Evidence
+
+The genes behind the label, and how the cluster differs from the clusters closest to it.
+
+- **Overview graphic:** the number of **Supporting**, **Missing** and **Unexpected** genes, drawn as a bar.
+- **Supporting:** genes whose expression supports the label, with an explanation.
+- **Missing:** genes expected for the label that are weak or absent in the cluster.
+- **Unexpected:** genes expressed in the cluster that are inconsistent with the label.
+- **Differentially Expressed vs Nearest Clusters:** one item per nearby cluster, with a volcano plot (log2FC against -log10 pAdj) and a table of the genes with pAdj below 0.05 and an absolute log2FC above 1 (columns **Gene**, **log2FC** and **pAdj**). Hover over a gene in the table to find it in the plot. A chip names the method:
+  - **Wilcoxon:** a cell-level test that treats every cell as independent, so p-values can overstate significance.
+  - **DESeq2:** a pseudobulk test in which samples, not cells, are the replicates.
+  - **Log2FC:** the fold change of the percentage of cells expressing each gene. No test is run, so there is no volcano plot and the table has no p-values.
+- **Co-expression Evidence:** for pairs of genes, the percentage of cells that express one gene only, both genes or neither, with the phi coefficient (Ο†, from -1 for mutually exclusive to +1 for always co-expressed) and a chi-squared p-value. It appears, for up to three pairs of a supporting and an unexpected gene, when the reviewer found the cluster heterogeneous and cell-level data was uploaded with the job.
+
+(report-cell-state-evidence)=
+### Cell State Evidence
+
+The cell state, kept separate from the cell's identity. Chips give the state (**State:**) and the number of gene sets. **Summary** explains the state, and **Supporting gene sets** lists named gene programs with their genes and reasoning. The section is absent when no state was called.
+
+(report-quality-control)=
+### Quality Control
+
+The independent reviewer's assessment of the final label. Chips show **Confidence** (High, Moderate or Low) and **Heterogeneity** (Heterogeneous or Homogeneous).
+
+| Item | Content |
+|---|---|
+| Confidence | The reviewer's reasoning for the confidence level. "Confidence details not available." means the review did not run, or failed, for this cluster (see [Reviewer confidence](interpreting-results.md#interpret-confidence)). |
+| Heterogeneity | The reasoning for the heterogeneity call. |
+| Cell classifier | Results of a classifier trained on the marker genes of all clusters and tested on cells it did not see during training. The **accuracy** chip shows how well the genes separate this cluster's cells from the others (green from 85%, amber from 65%, red below). The **Entropy** chip is the mean uncertainty of the per-cell predictions; lower means the cells look more alike. The bars show the labels the classifier gave to this cluster's cells. It needs cell-level data uploaded with the job. |
+| Reviewer Panel | Several reviewer perspectives, each with a specialty (for example a T cell immunologist), a one-line conclusion, and a **Strength**, **Weakness**, **Biological interpretation** and **Alternatives**. These assessments are generated by CyteType's models, not written by human experts. |
+| Markers from External Databases | How the cluster's genes compare with reference marker lists, and the **Database markers** from SingleCellBase, CellGuide Canonical and CellGuide Computational. |
+
+(report-context-fit)=
+### Context Fit
+
+How the label fits the study and the cluster.
+
+- **Tissue:** how the identity fits the tissue and the study context.
+- **Cluster:** how it fits this cluster's own context. **Cluster metadata** has a **Summary**, **Keywords**, and the **Full Metadata Breakdown (%)**: the percentage of the cluster's cells in each category of your categorical cell metadata columns.
+- **Pathways Enriched/Deleted:** the **Supporting pathways** for the identity with an explanation and a **Pathway summary**, followed by **Detailed pathway data** for each pathway library. Each table lists the **Pathway** and its normalized enrichment score (**NES**). The **Single sample** tab ranks genes by the share of this cluster's cells that express them; the **Differential** tab ranks them against the average of all clusters. A positive NES means the pathway's genes rank high, a negative NES that they rank low. Only pathways with a false discovery rate below 0.01 are listed, up to 50 per library and tab.
+
+(report-summary)=
+## Study summary
+
+With no cluster selected (click **Dataset Overview**), the right panel shows:
+
+- **Study Overview:** CyteType's description of the study, built from your study context and the data. Python returns the same text as `studyContext` from `get_results()`.
+- **Marker Gene Expression:** a dot plot with one column per cluster, labeled with its cell type and ordered by display group. The rows are up to three key supporting genes per cluster, grouped by display group. Dot size is the percentage of cells that express the gene; color is the mean expression when cell-level data was uploaded with the job, and otherwise the percentage. Hover over a dot to see the values. The plot appears once the job has finished.
+
+The embedding colors every cluster in this view, and the center column shows the study information.
+
+(report-compare)=
+## Compare with your own labels
+
+The report treats the labels in your cluster column (`group_key`) as your labels. Switch **Labels** to **User Label** to show them in the cluster list and on the embedding; the dots then show each cluster's **Match** score, and the cluster card shows the score next to your label.
+
+The Match score is the similarity between your label and CyteType's label, from 0% to 100%. The filter bands are High (80% or more), Moderate (40% to 79%) and Low (below 40%). Use the **Match** filter to focus on the clusters where you and CyteType disagree.
+
+There is no score when your labels are cluster numbers or generic names (such as `0`, `C1`, `cluster 3` or `unknown`); such clusters count as No Original Label. To compare CyteType with an existing annotation, run it with that annotation column as `group_key`, with marker genes computed for the same column (see [Prepare your data](../guides/prepare-data.md)). [Interpret and review results](interpreting-results.md#interpret-compare) explains how the score is calculated and how to read it.
+
+(report-csv)=
+## Download as CSV
+
+**Download as CSV** saves `cytetype_annotations_.csv`, with one row per finished cluster. Clusters that are still running are left out.
+
+| Column | Content |
+|---|---|
+| `cluster_id` | CyteType's cluster ID. |
+| `cell_type` | The CyteType label. |
+| `coarse_cell_type` | The display group (the Biological Order header). |
+| `cell_state` | The cell state. |
+| `cl_term` | The Cell Ontology term name. |
+| `cl_term_id` | The Cell Ontology ID. |
+| `confidence` | The reviewer's confidence: High, Moderate or Low (Unknown when there is none). |
+| `is_heterogeneous` | `TRUE` or `FALSE`. |
+| `supporting_genes` | The key supporting genes, separated by `;`. |
+| `cell_type_abbrv_label` | The abbreviated label shown on the embedding. |
+| `color_hex` | The cluster's color in the report. |
+| `author_label` | Your label for the cluster. |
+| `author_label_similarity_score` | The Match score from 0 to 100; empty when there is none. |
+| `l1`, `l2`, `l3`, `l4` | The annotation hierarchy levels. For an identity outside the hierarchy, `l1` holds the identity and `l2` to `l4` are empty. |
+| `qualifier` | The distinguishing feature added by label refinement, if any. |
+
+The CSV leaves out the evidence text, the reviewer panel, the heterogeneity type and level L5. The Python client returns these (see [Results in AnnData and Seurat](results-in-your-data.md)).
+
+## See also
+
+- [Interpret and review results](interpreting-results.md): how to read the labels, confidence and heterogeneity.
+- [Results in AnnData and Seurat](results-in-your-data.md): the same results in your object.
+- [Visibility and sharing](../platform/sharing.md), [Dashboard](../platform/dashboard.md) and [Chat](../platform/chat.md).
diff --git a/docs/source/results/results-in-your-data.md b/docs/source/results/results-in-your-data.md
new file mode 100644
index 0000000..39ae2e7
--- /dev/null
+++ b/docs/source/results/results-in-your-data.md
@@ -0,0 +1,419 @@
+---
+description: What CyteType adds to your AnnData or Seurat object, what get_results() and GetResults() return, and how to save, reload, plot and export the results.
+---
+
+(results-in-your-data)=
+# Results in AnnData and Seurat
+
+When a job finishes, the client writes the results into your object: one label per cell in the cell metadata, and the per-cluster results next to it. The [interactive report](report.md) holds the full evidence for every cluster; this page covers what lands in your data and how to use it.
+
+The examples use the cluster column `leiden` in Python and `seurat_clusters` in R, with the default `results_prefix = "cytetype"`. Column and key names change with both.
+
+(results-return-value)=
+## Keep the returned object
+
+::::{tab-set}
+:::{tab-item} Python
+:sync: python
+`run()` returns the annotated AnnData. It is the object you passed to `CyteType()`, except when `label_na=True` relabeled missing cluster labels: then it is a new object, and only that object carries the results. Always assign the return value.
+
+```python
+adata = annotator.run(study_context="Healthy human PBMCs, 10x Genomics 3' scRNA-seq")
+```
+:::
+:::{tab-item} R
+:sync: r
+`CyteTypeR()` returns a modified copy of your Seurat object; R does not change the object you pass in. Assign the result. If you don't, the labels are not stored in your session, although the report still has them and `GetResults(job_id = ...)` can fetch them.
+
+```r
+obj <- CyteTypeR(obj, prepped_data, study_context = "Healthy human PBMCs, 10x Genomics 3' scRNA-seq")
+```
+
+If `CyteTypeR()` stops before it returns, because `timeout_seconds` ran out or you interrupted it, the job keeps running on the server but your object gets no results. Fetch them with `GetResults(job_id = "")`, using the job ID from the message (see [Fetch results later](../guides/running-jobs.md#running-jobs-fetch-later)).
+:::
+::::
+
+(results-cell-columns)=
+## Cell-level columns
+
+Annotation happens per cluster, so every cell in a cluster gets the same values.
+
+::::{tab-set}
+:::{tab-item} Python
+:sync: python
+`run()` adds five categorical columns to `adata.obs`, named `{results_prefix}__{group_key}`:
+
+| Column | Content |
+|---|---|
+| `cytetype_annotation_leiden` | The cluster's cell type label, for example `Classical Monocyte`. |
+| `cytetype_cellOntologyTerm_leiden` | The Cell Ontology term name for the label, for example `classical monocyte`. Can be empty when no term matched. |
+| `cytetype_cellOntologyTermID_leiden` | The Cell Ontology ID, for example `CL:0000860`. Can be empty when no term matched. |
+| `cytetype_cellState_leiden` | The cell state, for example `proliferating`. Empty when no state was called. |
+| `cytetype_reviewerConfidence_leiden` | The reviewer's confidence: `High`, `Moderate` or `Low`, or `Unknown` when the result has none. |
+
+The categories of the confidence column are listed as `High`, `Moderate`, `Low`, `Unknown` (only those that occur), so legends follow that order; the categorical is not ordered, so comparisons such as `>` do not apply. Cells in clusters without a result get `Unknown` (an empty value for the cell state).
+:::
+:::{tab-item} R
+:sync: r
+`CyteTypeR()` adds factor columns to `obj@meta.data`. The first five use the same names as the Python client; the last three are legacy copies kept for scripts written against earlier versions:
+
+| Column | Content |
+|---|---|
+| `cytetype_annotation_seurat_clusters` | The cluster's cell type label. |
+| `cytetype_cellOntologyTerm_seurat_clusters` | The Cell Ontology term name. |
+| `cytetype_cellOntologyTermID_seurat_clusters` | The Cell Ontology ID. |
+| `cytetype_cellState_seurat_clusters` | The cell state. Empty when no state was called. |
+| `cytetype_reviewerConfidence_seurat_clusters` | The reviewer's confidence: `High`, `Moderate` or `Low`, or `Unknown` when the result has none. |
+| `cytetype_seurat_clusters` | Legacy copy of the label column. |
+| `cytetype_ontologyTerm_seurat_clusters` | Legacy copy of the term name column. |
+| `cytetype_ontologyID_seurat_clusters` | Legacy copy of the term ID column. |
+
+Prefer the new names. The levels of the confidence column are `High`, `Moderate`, `Low`, `Unknown` (only those that occur), so legends follow that order; the factor is not ordered, so comparisons such as `>` do not apply. Cells in clusters without a result get `Unknown` (an empty value for the cell state) in the first five columns and `NA` in the legacy columns.
+:::
+::::
+
+Cell Ontology IDs use the `CL:0000860` form (occasionally `PCL:`, the Provisional Cell Ontology). Results from older jobs can use the `CL_0000860` form.
+
+(results-cluster-ids)=
+## Cluster IDs and your labels
+
+The cell-level columns are already mapped to cells. Per-cluster results, the report and its CSV export identify clusters by CyteType's cluster ID, which can differ from the labels in your cluster column. In the report and the CSV, `cluster_id` is CyteType's ID and `author_label` is your label.
+
+::::{tab-set}
+:::{tab-item} Python
+:sync: python
+If every label in the cluster column has at most three characters (for example Leiden numbers), CyteType uses the labels themselves as IDs. Otherwise it numbers the clusters `0`, `1`, `2`, ... in natural sort order of the labels. With `label_na=True`, the added label `Unknown` is longer than three characters, so all clusters are then numbered. `annotator.cluster_map` maps each of your labels to its ID:
+
+```python
+id_to_label = {cluster_id: label for label, cluster_id in annotator.cluster_map.items()}
+```
+:::
+:::{tab-item} R
+:sync: r
+CyteTypeR always numbers clusters `1`, `2`, `3`, ... in the sorted order of your labels (level order for a factor such as `seurat_clusters`), so Seurat cluster `0` is CyteType cluster `1`. `prepped_data$clusterLabels` and `obj@misc$cytetype_jobDetails$cluster_labels` map each ID to your label. The table in `obj@misc$cytetype_results` already uses your labels.
+
+```r
+id_to_label <- unlist(prepped_data$clusterLabels)
+id_to_label[["1"]]  # your label for CyteType cluster 1
+```
+:::
+::::
+
+(results-per-cluster)=
+## Per-cluster results
+
+::::{tab-set}
+:::{tab-item} Python
+:sync: python
+`get_results()` returns a dictionary:
+
+```python
+results = annotator.get_results()
+```
+
+| Key | Content |
+|---|---|
+| `annotations` | A list with one dictionary per annotated cluster (fields below). |
+| `clusterCategories` | Display groups: a list of dictionaries with `categoryName`, `clusterIds` and `color`. They define the report's Biological Order and the grouping in `marker_dotplot()`. |
+| `studyContext` | CyteType's description of the study, shown as Study Overview in the report. |
+| `summary` | A study-level assessment from earlier pipeline versions; empty for current jobs. |
+| `raw_annotations` | The server's per-cluster results, unchanged (see [Raw results](#results-raw)). |
+
+Each entry in `annotations` has these keys:
+
+| Key | Type | Content |
+|---|---|---|
+| `clusterId` | str | CyteType's cluster ID. |
+| `annotation` | str | The cell type label (as in the obs annotation column). |
+| `ontologyTerm` | str | The Cell Ontology term name. |
+| `ontologyTermID` | str | The Cell Ontology ID. |
+| `granularAnnotation` | str | A one-sentence description that combines the cell type, its state and key genes. |
+| `cellState` | str | The cell state; empty when none was called. |
+| `justification` | str | A summary of the evidence for the label. |
+| `supportingMarkers` | list of str | Genes whose expression supports the label. |
+| `conflictingMarkers` | list of str | Genes expressed in the cluster that are inconsistent with the label. |
+| `missingExpression` | str | Genes expected for the label that are weak or absent, with an explanation. |
+| `unexpectedExpression` | str | An explanation of the inconsistent genes. |
+| `reviewerConfidence` | str or None | `High`, `Moderate` or `Low`. |
+| `isHeterogeneous` | bool or None | Whether the reviewer judged that the cluster contains distinct populations. |
+| `heterogeneityType` | str or None | `distinct_populations`, `mixed_lineage`, `doublet_artifact`, `transitional_state` or `unresolved`. None for homogeneous clusters and when the type was not classified. |
+
+[Interpret and review results](interpreting-results.md) explains how to read the confidence and heterogeneity values.
+
+`adata.uns` holds two entries:
+
+| Key | Content |
+|---|---|
+| `cytetype_results` | `{"job_id": ..., "result": ...}`, where `result` is the `get_results()` dictionary stored as a JSON string so that the AnnData can be written to h5ad. |
+| `cytetype_jobDetails` | `job_id`, `report_url` and `api_url`, plus `visibility` when you passed it to `run()`. |
+:::
+:::{tab-item} R
+:sync: r
+`CyteTypeR()` stores two entries in `obj@misc`:
+
+| Entry | Content |
+|---|---|
+| `obj@misc$cytetype_results` | A data.frame with one row per annotated cluster (columns below). The row names and `clusterId` are your cluster labels. Its attributes `"job_id"` and `"result"` hold the job ID and the full results. |
+| `obj@misc$cytetype_jobDetails` | `job_id`, `report_url`, `api_url`, `group_key` and `cluster_labels` (CyteType ID to your label), plus `visibility` when you passed it to `CyteTypeR()`. |
+
+```r
+res <- obj@misc$cytetype_results
+full <- attr(res, "result")
+```
+
+The data.frame has these columns:
+
+| Column | Type | Content |
+|---|---|---|
+| `clusterId` | character | Your cluster label. |
+| `annotation` | character | The cell type label (as in the meta.data annotation column). |
+| `ontologyTerm` | character | The Cell Ontology term name. |
+| `ontologyTermID` | character | The Cell Ontology ID. |
+| `granularAnnotation` | character | A one-sentence description that combines the cell type, its state and key genes. |
+| `cellState` | character | The cell state; empty when none was called. |
+| `justification` | character | A summary of the evidence for the label. |
+| `supportingMarkers` | character | Genes whose expression supports the label, separated by `"; "`. |
+| `conflictingMarkers` | character | Genes expressed in the cluster that are inconsistent with the label, separated by `"; "`. |
+| `missingExpression` | character | Genes expected for the label that are weak or absent, with an explanation. |
+| `unexpectedExpression` | character | An explanation of the inconsistent genes. |
+| `reviewerConfidence` | character | `High`, `Moderate` or `Low`; `NA` when missing. |
+| `isHeterogeneous` | logical | Whether the reviewer judged that the cluster contains distinct populations; `NA` when missing. |
+| `heterogeneityType` | character | `distinct_populations`, `mixed_lineage`, `doublet_artifact`, `transitional_state` or `unresolved`. `NA` for homogeneous clusters and when the type was not classified. |
+
+Clusters without a result have no row. [Interpret and review results](interpreting-results.md) explains how to read the confidence and heterogeneity values.
+
+The full results are a list with `annotations` (one list per annotated cluster with the fields of the table, where `clusterId` is CyteType's ID and `supportingMarkers` and `conflictingMarkers` are lists of genes), `raw_annotations` (the server's per-cluster results, unchanged; see [Raw results](#results-raw)) and the server's `studyContext`, `summary`, `semanticOrder`, `clusterCategories` and `displayLevel`.
+
+A run with another prefix, for example `results_prefix = "run2"`, stores `obj@misc$run2_results` (a list with `job_id` and `result`, the full results) and `obj@misc$run2_jobDetails`, and leaves `obj@misc$cytetype_results` as it is.
+
+`GetResults()` returns different things depending on how you call it:
+
+| Call | Returns |
+|---|---|
+| `GetResults(obj)` after a run with the default prefix | The data.frame in `obj@misc$cytetype_results`. |
+| `GetResults(obj, results_prefix = "run2")` after a run with that prefix | The full results. |
+| `GetResults(job_id = "")` | For a completed job, a data.frame with the same columns and attributes as `obj@misc$cytetype_results`, where `clusterId` is CyteType's ID. It also saves the server's response as `cytetypeR_results_.json` in the working directory, unless you pass `save_json = FALSE`. While the job is still running, and for a job ID the server does not know, it returns `NULL` with a message; a failed job is an error. |
+:::
+::::
+
+(results-evidence-fields)=
+:::{note}
+**Evidence fields for current jobs.** The server stores the evidence for current jobs in a structured form inside the raw results. The Python client 0.21.0 and CyteTypeR 0.6.0 fill the five evidence fields from it: `justification` from the conclusion for the label (the **Summary** in the report's Justification section), `supportingMarkers` from the key supporting genes, `conflictingMarkers` from the unexpected genes, and `missingExpression` and `unexpectedExpression` from the explanations (or from the gene lists when there is no explanation). Values the server stored are never overwritten. The server applies the same rules when it returns results, so earlier Python versions also receive these fields filled.
+:::
+
+`get_results()` returns the results stored in `adata.uns` without contacting the server. If there are none but `adata.uns` holds the job details, for example because `run()` stopped waiting, it checks the job once and, if the job has finished, downloads the results and adds the columns as `run()` would. It returns `None` when nothing is available yet, and raises `AuthenticationError` when it needs the server but finds no API key. [Run, monitor and rerun jobs](../guides/running-jobs.md) covers recovering results this way.
+
+(results-raw)=
+## Raw results
+
+:::{warning}
+The raw results follow the server's internal format, which can change between releases. Prefer the documented fields above in pipelines, and pin the client version when your code depends on a raw field.
+:::
+
+Some details exist only in the raw results, such as the annotation levels L1 to L5 (see [Identity, levels and state](interpreting-results.md#interpret-levels)), the classification and the reviewer's reasoning. They are in `raw_annotations` of the full results: `results["raw_annotations"]` in Python, a dictionary keyed by CyteType cluster ID, and `attr(obj@misc$cytetype_results, "result")$raw_annotations` in R, a named list. Each value has `latest` (the cluster's newest result) and `history` (earlier results for the same cluster, usually empty). The fields people use most:
+
+| Path under `raw_annotations[cluster_id]` | Content |
+|---|---|
+| `latest.annotation.fullOutput.cellType.annotationLevels` | The hierarchy from `L1` (broadest) to `L5` (most specific). Each level has a `label`; levels can be missing. |
+| `latest.annotation.fullOutput.cellType.classification` | How the label relates to the reference hierarchy: `catalogue`, `outOfCatalog` or `unresolved` for current jobs (see [Classification](interpreting-results.md#interpret-classification)). |
+| `latest.annotation.fullOutput.cellType.keySupportingGenes`, `missingGenes`, `unexpectedGenes` | The gene lists behind the evidence fields. |
+| `latest.annotation.fullOutput.cellStateGeneGroups` | Gene sets that support the cell state. |
+| `latest.review.fullOutput.confidenceAssessment` | The reviewer's confidence `label` and its `reasoning`. |
+| `latest.review.fullOutput.heterogeneityAssessment` | `label`, `reasoning`, `heterogeneityType` and `heterogeneityTypeReasoning`. |
+| `latest.review.labelComparisonScore` | The similarity between CyteType's label and your cluster label, 0 to 100. `labelComparisonLlmScore` holds the fallback estimate when this is empty. |
+
+::::{tab-set}
+:::{tab-item} Python
+:sync: python
+Build a table of the annotation levels:
+
+```python
+import pandas as pd
+
+rows = []
+for cluster_id, cluster in results["raw_annotations"].items():
+    annotation = (cluster.get("latest") or {}).get("annotation") or {}
+    cell_type = (annotation.get("fullOutput") or {}).get("cellType") or {}
+    levels = cell_type.get("annotationLevels") or {}
+    rows.append({
+        "clusterId": cluster_id,
+        **{name: (levels.get(name) or {}).get("label", "") for name in ["L1", "L2", "L3", "L4", "L5"]},
+    })
+levels_table = pd.DataFrame(rows)
+```
+:::
+:::{tab-item} R
+:sync: r
+Build a table of the annotation levels:
+
+```r
+raw <- attr(obj@misc$cytetype_results, "result")$raw_annotations
+
+# The labels of the levels L1 to L5 of one cluster ("" when a level is missing).
+level_labels <- function(cluster) {
+  levels <- cluster$latest$annotation$fullOutput$cellType$annotationLevels
+  vapply(paste0("L", 1:5), function(name) {
+    label <- levels[[name]]$label
+    if (is.null(label)) "" else label
+  }, character(1))
+}
+levels_table <- data.frame(clusterId = names(raw), t(vapply(raw, level_labels, character(5))))
+```
+
+The names of `raw` are CyteType's cluster IDs. For a job fetched with `GetResults(job_id = ...)`, use `attr(res, "result")$raw_annotations`.
+:::
+::::
+
+(results-save)=
+## Save and reload
+
+::::{tab-set}
+:::{tab-item} Python
+:sync: python
+The results live in `adata.obs` and `adata.uns`, so they are saved with the AnnData. Call `annotator.cleanup()` first if you want to remove the upload files and the temporary gene symbol column that CyteType added to `adata.var`.
+
+```python
+import json
+import anndata as ad
+
+adata.write_h5ad("annotated.h5ad")
+
+adata = ad.read_h5ad("annotated.h5ad")
+results = json.loads(adata.uns["cytetype_results"]["result"])
+report_url = adata.uns["cytetype_jobDetails"]["report_url"]
+```
+
+Parsing the JSON returns the results exactly as they were stored. Files written by 0.21.0 already contain `reviewerConfidence`, `isHeterogeneous` and `heterogeneityType` and the filled evidence fields. For files written by older versions, `get_results()` and `marker_dotplot()` in 0.21.0 normalize the stored results when they read them: they fill the evidence fields and add the three review keys (as `None` for files from version 0.7.0 and older, which have no raw results). The `reviewerConfidence` column is not added to `adata.obs` of older files. `get_results()` is a method of the `CyteType` object, so in a new session parsing the JSON is the simplest way to read the results.
+:::
+:::{tab-item} R
+:sync: r
+The columns and the `misc` entries are saved with the Seurat object:
+
+```r
+saveRDS(obj, "annotated.rds")
+
+obj <- readRDS("annotated.rds")
+res <- GetResults(obj)
+obj@misc$cytetype_jobDetails$report_url
+```
+:::
+::::
+
+(results-plot)=
+## Plot the results
+
+::::{tab-set}
+:::{tab-item} Python
+:sync: python
+Color the embedding by the label or by the reviewer's confidence:
+
+```python
+import scanpy as sc
+
+sc.pl.umap(adata, color="cytetype_annotation_leiden")
+
+confidence_colors = {"High": "#1a9850", "Moderate": "#fdae61", "Low": "#d73027", "Unknown": "#bdbdbd"}
+sc.pl.umap(adata, color="cytetype_reviewerConfidence_leiden", palette=confidence_colors)
+```
+
+`marker_dotplot()` draws a Scanpy dot plot of the genes that support each label, with the labels ordered and the genes grouped by display group (the `clusterCategories`):
+
+```python
+from cytetype import marker_dotplot
+
+marker_dotplot(adata, group_key="leiden")
+marker_dotplot(adata, group_key="leiden", n_top_markers=5, standard_scale="var")
+```
+
+| Argument | Default | Meaning |
+|---|---|---|
+| `adata` | | The annotated AnnData; the results are read from `adata.uns`. |
+| `group_key` | | The cluster column used for the run, for example `"leiden"`. |
+| `results_prefix` | `"cytetype"` | Must match the `results_prefix` of the run. |
+| `n_top_markers` | `3` | Number of key supporting genes shown per cluster. |
+| `gene_symbols` | `None` | A column of `adata.var` with gene symbols, if `var_names` are IDs. It is passed to `sc.pl.dotplot`. |
+| `**kwargs` | | Passed to `sc.pl.dotplot`, for example `standard_scale`, `cmap`, `use_raw`, `layer`, `show`, `save` or `return_fig`. |
+
+Gene names are matched without regard to case, because CyteType reports upper-case symbols (for example `CD8A` for mouse `Cd8a`). Genes that are not found are skipped with a warning. Like Scanpy, the function uses `adata.raw` when it exists and you pass no `layer`. It needs Scanpy, and it raises an error when the results have no display groups (some older jobs) or no raw results (files from version 0.7.0 and older).
+:::
+:::{tab-item} R
+:sync: r
+Color the embedding by the label or by the reviewer's confidence:
+
+```r
+library(Seurat)
+
+DimPlot(obj, group.by = "cytetype_annotation_seurat_clusters", label = TRUE, repel = TRUE) + NoLegend()
+
+confidence_colors <- c(High = "#1a9850", Moderate = "#fdae61", Low = "#d73027", Unknown = "#bdbdbd")
+DimPlot(obj, group.by = "cytetype_reviewerConfidence_seurat_clusters", cols = confidence_colors)
+```
+
+CyteTypeR has no dot plot helper, but the `supportingMarkers` column of the results table lists the genes that support each label, so you can plot them with Seurat's `DotPlot()`:
+
+```r
+res <- obj@misc$cytetype_results
+genes <- unique(unlist(lapply(strsplit(res$supportingMarkers, "; ", fixed = TRUE), head, 3)))
+
+# CyteType reports upper-case symbols; match them to the case used in your object
+genes <- rownames(obj)[match(toupper(genes), toupper(rownames(obj)))]
+genes <- unique(genes[!is.na(genes)])
+
+DotPlot(obj, features = genes, group.by = "cytetype_annotation_seurat_clusters") + RotatedAxis()
+```
+
+:::
+::::
+
+(results-export)=
+## Export a per-cluster table
+
+The report's **Download as CSV** button exports one row per cluster with the label, confidence, heterogeneity, supporting genes and annotation levels (see [Download as CSV](report.md#report-csv)). To build a table from your own object:
+
+::::{tab-set}
+:::{tab-item} Python
+:sync: python
+From `get_results()`, with your cluster labels added:
+
+```python
+import pandas as pd
+
+results = annotator.get_results()
+table = pd.DataFrame(results["annotations"])
+for column in ["supportingMarkers", "conflictingMarkers"]:
+    table[column] = table[column].apply(lambda genes: ";".join(genes) if isinstance(genes, list) else "")
+
+id_to_label = {cluster_id: label for label, cluster_id in annotator.cluster_map.items()}
+table.insert(1, "leiden", table["clusterId"].map(id_to_label))
+table.to_csv("cytetype_clusters.csv", index=False)
+```
+
+From the obs columns, which also works on a reloaded file without the `CyteType` object:
+
+```python
+group_key = "leiden"
+fields = ["annotation", "cellOntologyTerm", "cellOntologyTermID", "cellState", "reviewerConfidence"]
+columns = [f"cytetype_{field}_{group_key}" for field in fields]
+
+per_cluster = adata.obs.groupby(group_key, observed=True)[columns].first()
+per_cluster["n_cells"] = adata.obs[group_key].value_counts()
+per_cluster.to_csv("cytetype_clusters.csv")
+```
+:::
+:::{tab-item} R
+:sync: r
+From the results table, which already uses your cluster labels:
+
+```r
+res <- obj@misc$cytetype_results
+write.csv(res, "cytetype_clusters.csv", row.names = FALSE)
+```
+:::
+::::
+
+## See also
+
+- [The CyteType report](report.md): the evidence behind each label.
+- [Interpret and review results](interpreting-results.md): how to read confidence, heterogeneity and classifications.
+- [Run, monitor and rerun jobs](../guides/running-jobs.md): fetch results after a disconnect and rerun with new settings.
+- [Python API reference](../reference/api/index.html) and [R function reference](../reference/r-api.md).
diff --git a/docs/source/support/citing.md b/docs/source/support/citing.md
new file mode 100644
index 0000000..ab76b7a
--- /dev/null
+++ b/docs/source/support/citing.md
@@ -0,0 +1,129 @@
+---
+description: How to cite CyteType, what to record so that others can inspect and reproduce your annotations, and the licenses that apply to the clients and the service.
+---
+
+(citing)=
+# Cite CyteType and report versions
+
+This page gives the reference to cite, the details to record for each run so that others can inspect and reproduce your annotations, a methods template, and the licenses of the clients and the service.
+
+(citing-preprint)=
+## Cite the preprint
+
+If you use CyteType in your work, please cite the preprint:
+
+> Ahuja G, Antill A, Su Y, Dall'Olio GM, Basnayake S, Karlsson G, Dhapola P. Multi-agent AI enables evidence-based cell annotation in single-cell transcriptomics. *bioRxiv* 2025. doi:[10.1101/2025.11.06.686964](https://doi.org/10.1101/2025.11.06.686964)
+
+BibTeX:
+
+```bibtex
+@article{cytetype2025,
+  title   = {Multi-agent {AI} enables evidence-based cell annotation in single-cell transcriptomics},
+  author  = {Ahuja, Gautam and Antill, Alex and Su, Yi and Dall'Olio, Giovanni Marco and Basnayake, Sukhitha and Karlsson, G{\"o}ran and Dhapola, Parashar},
+  journal = {bioRxiv},
+  year    = {2025},
+  doi     = {10.1101/2025.11.06.686964},
+  url     = {https://www.biorxiv.org/content/10.1101/2025.11.06.686964v1}
+}
+```
+
+The **Citation** link at the bottom of the report's sidebar also opens the preprint.
+
+The preprint describes an earlier version of the CyteType workflow, and the service has changed since. Report the client version and the date of your run as well, as described below.
+
+(citing-record)=
+## Record what you ran
+
+The annotation runs on CyteType's servers, and the workflow and the models it uses are updated over time: a client you installed earlier sends its jobs to the current workflow. Runs are also not deterministic, so the same data can give slightly different results. Record these items for every run you report:
+
+| Item | Where to find it |
+|---|---|
+| Job ID | Python: `adata.uns["cytetype_jobDetails"]["job_id"]`. R: `pbmc@misc$cytetype_jobDetails$job_id`. It is also part of the report link and listed in the [dashboard](../platform/dashboard.md). |
+| Report link | Python: `adata.uns["cytetype_jobDetails"]["report_url"]`. R: `pbmc@misc$cytetype_jobDetails$report_url`. |
+| Client and version | Python: `cytetype.__version__` or `cytetype --version`. R: `packageVersion("CyteTypeR")`. The report's study information shows them too. |
+| Date of the run | The **Created** column of the report list in the dashboard. |
+| Request | `query.json`, saved in your working directory unless you pass `save_query=False` (R: `save_query = FALSE`). It holds the study context, metadata tags, cluster labels, marker genes, expression summaries and the client version. |
+| Study context | The `study_context` text you passed. |
+| Your settings | The cluster column (`group_key`), how you computed the marker genes (method and parameters), `n_top_genes`, `results_prefix` and, if you used your own LLM, the provider and model names. Never record API keys. |
+| Results | Python: the `cytetype_*` columns of `adata.obs` and `adata.uns["cytetype_results"]`. R: the `cytetype_*` columns of `pbmc@meta.data` and `pbmc@misc$cytetype_results`. In the report: **Download as CSV**. See [Results in AnnData and Seurat](../results/results-in-your-data.md). |
+
+If you passed another `results_prefix`, replace `cytetype` in these names with your prefix.
+
+To print the main items:
+
+::::{tab-set}
+:::{tab-item} Python
+:sync: python
+```python
+import cytetype
+
+details = adata.uns["cytetype_jobDetails"]
+print("cytetype", cytetype.__version__)
+print("Job ID:", details["job_id"])
+print("Report:", details["report_url"])
+```
+:::
+:::{tab-item} R
+:sync: r
+```r
+details <- pbmc@misc$cytetype_jobDetails
+packageVersion("CyteTypeR")
+details$job_id
+details$report_url
+```
+:::
+::::
+
+`query.json` records what you sent, but not the server version or the models that served the job. If you need that level of provenance for a publication, contact contact@nygen.io with the job ID.
+
+:::{warning}
+In CyteTypeR, `query.json` also contains your `llm_configs`, including provider API keys. Remove the keys before you share the file, or pass `save_query = FALSE`. The Python client never writes `llm_configs` to `query.json`.
+:::
+
+(citing-methods)=
+## Describe CyteType in your methods
+
+Adapt this template to your analysis:
+
+::::{tab-set}
+:::{tab-item} Python
+:sync: python
+```text
+Clusters were annotated with CyteType (Ahuja et al., 2025) using the cytetype
+Python client, version , on  (job ). Marker genes were the
+top  genes per cluster from Scanpy's rank_genes_groups ().
+The study context was: "". The annotation report is available at
+.
+```
+:::
+:::{tab-item} R
+:sync: r
+```text
+Clusters were annotated with CyteType (Ahuja et al., 2025) using the CyteTypeR
+package, version , on  (job ). Marker genes were the top
+ genes per cluster by average log2 fold change from Seurat's
+FindAllMarkers (). The study context was: "". The
+annotation report is available at .
+```
+:::
+::::
+
+If you used your own LLM, also name the provider and model.
+
+(citing-share)=
+## Share the report with readers
+
+- **Make it public.** New reports are private. To let reviewers and readers open the report, set its visibility to **Public** in the dashboard, then cite the link: anyone with the link can view a public report. A public report is also listed in Chat's report search for every signed-in CyteType user, who can then query its uploaded cell-level data, including all of your cell metadata columns. Check that you may publish all of it first; [Visibility and sharing](../platform/sharing.md) lists what a public report exposes.
+- **Keep it.** Deleting a report breaks its link for everyone and cannot be undone from the dashboard. Keep the reports you cite.
+- **Add a table.** **Download as CSV** in the report's toolbar gives one row per cluster, which works well as a supplementary table. See [Download as CSV](../results/report.md#report-csv) for its columns.
+
+(citing-licenses)=
+## Licenses
+
+| Component | License or terms |
+|---|---|
+| `cytetype`, the Python client | BSD 3-Clause |
+| `CyteTypeR`, the R client | BSD 3-Clause |
+| The CyteType service (annotation, report, dashboard, Chat and MCP connector) | Free for academic and non-commercial research within the [usage limits](../platform/limits.md). Commercial use requires a license from Nygen Analytics AB. |
+
+The client licenses cover the code of the two packages. The service terms cover the annotations and other data that the service generates: data generated under free academic use is licensed under [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/), and a commercial license includes commercial rights to the generated data. For commercial licensing, contact contact@nygen.io.
diff --git a/docs/source/support/faq.md b/docs/source/support/faq.md
new file mode 100644
index 0000000..c9b1ed7
--- /dev/null
+++ b/docs/source/support/faq.md
@@ -0,0 +1,156 @@
+---
+description: Short answers to common questions about CyteType, its data requirements, analysis, privacy, access and pricing, and support.
+---
+
+(faq)=
+# FAQ
+
+Short answers to common questions, with links to the pages that cover each topic in depth. For problems and error messages, see [Troubleshooting](troubleshooting.md).
+
+(faq-general)=
+## General
+
+### What is CyteType?
+
+CyteType annotates the clusters of single-cell RNA-seq data. You prepare your clustered data with the Python client (AnnData and Scanpy) or the R client (Seurat), and CyteType's servers run a structured workflow: they read your study context, weigh competing cell type candidates for each cluster against the marker and expression evidence, link the chosen identity to the Cell Ontology, compare related clusters, and have an independent reviewer assess each call. The results come back into your data object and into an interactive [report](../results/report.md). CyteType is developed by Nygen Analytics AB in Sweden. See [How CyteType works](../getting-started/how-cytetype-works.md).
+
+The [2025 preprint](citing.md) describes an earlier version of the workflow. The hosted service has changed since, so record the client version and run date when you publish (see [Cite CyteType and report versions](citing.md)).
+
+### Which environments are supported?
+
+- **Python:** the `cytetype` package (Python 3.12 or newer) for AnnData objects, usually prepared with Scanpy.
+- **R:** the `CyteTypeR` package for Seurat objects.
+
+Both clients share one sign-in. Start with [Installation](../getting-started/installation.md), then the [Python quickstart](../getting-started/quickstart.ipynb) or the [R quickstart](../getting-started/quickstart-r.md).
+
+### Does CyteType run on my computer?
+
+Partly. The client prepares your data on your computer: it extracts the marker genes, summarizes expression and metadata per cluster, and writes two files with your expression data and cell metadata. The annotation itself runs on CyteType's servers, so you need internet access and an API key. The clients cannot annotate offline.
+
+### Do I need an account?
+
+Yes. You sign in with an institutional email address (university, institute or company); addresses from personal email providers are not accepted. Signing in from the client saves an API key that it then uses for you. See [Sign in and API keys](../getting-started/sign-in.md).
+
+(faq-data)=
+## Data
+
+### What do I need before I run CyteType?
+
+- Log-normalized expression: `adata.X` in Python, the `RNA` assay after `NormalizeData()` in R.
+- A cluster column with a label for every cell.
+- Marker genes for those clusters: `sc.tl.rank_genes_groups()` with `groupby` set to the cluster column in Python, `FindAllMarkers()` in R.
+- Gene symbols, such as `CD3E`, rather than database IDs.
+
+A 2D embedding such as a UMAP, raw counts and cell metadata are optional but add to the report and the evidence. See [Prepare your data](../guides/prepare-data.md).
+
+### Does CyteType compute marker genes?
+
+No. You provide them, because the right marker method often depends on the study. CyteType takes the top markers of each cluster (`n_top_genes`, 50 by default) as the starting point. It adds the share of cells that express each gene in each cluster, computed for all genes, and uses the uploaded files to look up cell-level expression while it checks a call.
+
+### Which species are supported?
+
+Human and mouse. CyteType has not been validated on other organisms, and the clients do not map genes between species.
+
+### Can I use Ensembl IDs instead of gene symbols?
+
+No, CyteType needs gene symbols. The Python client looks for them in common `adata.var` columns (such as `gene_symbols` or `feature_name`) and in `adata.var_names`, and splits values like `TSPAN6_ENSG00000000003`. It stops with an error if about half or more of the names still look like IDs; then pass `gene_symbols_column` or convert the IDs first. CyteTypeR only warns, so make sure the row names of your Seurat object are symbols.
+
+### Does CyteType work on disease, tumor or perturbed samples?
+
+Yes. CyteType uses the disease, treatment and experimental conditions you describe in the [study context](../guides/study-context.md) instead of assuming healthy tissue. Check unusual or transitional populations against the evidence in the report.
+
+### Does CyteType handle spatial, multimodal or CITE-seq data?
+
+The clients take an expression matrix with clusters: an AnnData object, or the `RNA` assay of a Seurat object. There is no dedicated workflow for spatial, multimodal or protein data.
+
+### Does CyteType detect doublets or low-quality cells?
+
+It is not a quality-control tool. The reviewer flags heterogeneous clusters, including clusters that look like doublet artifacts, but you should remove doublets and low-quality cells during preprocessing.
+
+(faq-analysis)=
+## Analysis
+
+### Are annotations made per cell or per cluster?
+
+Per cluster. Every cell of a cluster gets that cluster's labels. To annotate at a finer level, cluster at a higher resolution (or subcluster a population) and run CyteType on those clusters.
+
+### Why do repeated runs give slightly different results?
+
+CyteType uses language models, so runs are not deterministic: wording, reasoning, confidence and sometimes the label can change between runs on the same data. Treat agreement across runs as supporting evidence, and record the job ID of each run you report.
+
+### How should I read the confidence label?
+
+The reviewer rates each annotation `High`, `Moderate` or `Low`. It is a categorical review signal, not a calibrated probability that the label is correct. Heterogeneity is assessed separately. Use both to decide which clusters to review first. See [Interpret and review results](../results/interpreting-results.md).
+
+### How long does a job take?
+
+It depends on the number of clusters, the size of the data and the load on the service. The report link printed when the job starts shows its progress live. Both clients wait up to 2 hours by default (`timeout_seconds`); if they stop waiting, the job keeps running. The server stops a job after 4 hours.
+
+### Does the cell sampling in the report change the annotation?
+
+No. For the report's embedding plot, the client samples up to `max_cells_per_group` cells per cluster (1000 by default). The expression summaries sent with the job are computed from all cells, and the uploaded files contain every cell.
+
+### Can I tell CyteType which tissue or cell types to expect?
+
+Describe the organism, tissue, disease and experiment in `study_context`. CyteType uses it to weigh the candidates, but it does not restrict the answer to a list you provide, so that plausible alternatives are not excluded too early. See [Study context and metadata](../guides/study-context.md).
+
+### Can I use my own language model?
+
+Yes. Pass `llm_configs` with a supported provider (for example `openai`, `anthropic`, `bedrock` or `openrouter`), a model name and your provider key; the model must support tool calling. Every model is checked before the job starts. The usage limits still apply, and [Chat](../platform/chat.md) always uses CyteType's hosted models. See [Use your own LLM](../guides/llm-configuration.md).
+
+### What if I disagree with an annotation?
+
+Open the cluster in the report and check its evidence: the supporting, missing and unexpected genes, the candidates that were considered and the reviewer's assessment. You can ask follow-up questions in [Chat](../platform/chat.md). Record your own conclusion in your analysis. To try again, for example with a more specific study context, run a new job with a different `results_prefix` so that both results are kept.
+
+(faq-privacy)=
+## Privacy
+
+### What data leaves my computer?
+
+A request with your study context, report metadata, cluster labels, marker genes, per-cluster expression and metadata summaries and sampled embedding coordinates, plus two files: `vars.h5` with the normalized expression matrix (and raw counts when found) and `obs.duckdb` with all cell metadata and the 2D coordinates. FASTQ or BAM files and your AnnData or Seurat object as a whole are not uploaded. See [Security and privacy](security-and-privacy.md).
+
+### Is my data used to train models?
+
+No. Your scientific data is used to run your jobs and the features you use on them, not to train or benchmark models. CyteType does collect operational telemetry to run and improve the service. See [Security and privacy](security-and-privacy.md).
+
+### Who can see my report?
+
+Only you, by default: new reports are private. You can share a report with specific people by email, make it visible to your organization (for reports submitted with an organization API key), or make it public. A public report can be opened by anyone with the link, and any signed-in CyteType user can also find it through Chat's report search and analyze it in Chat or through the MCP connector. See [Visibility and sharing](../platform/sharing.md), which lists what a public report exposes.
+
+### Can I delete a report?
+
+Yes, in the [dashboard](../platform/dashboard.md). Deletion cannot be undone from the dashboard: everyone, including you, loses access to the report, and its shares are removed. The underlying job data is kept on CyteType's storage, and deleted jobs still count toward usage limits. See [Security and privacy](security-and-privacy.md#security-and-privacy-deletion).
+
+### Can I use CyteType with confidential human data?
+
+CyteType does not detect or remove personal identifiers or protected health information. Remove identifiers and unneeded columns from your cell metadata (all of it is uploaded in `obs.duckdb`), keep the study context and metadata free of identifying text, and complete your institution's review first. There is no mode that uploads only summaries. A data processing agreement is available with a commercial license. See [Security and privacy](security-and-privacy.md).
+
+(faq-access)=
+## Access and pricing
+
+### Is CyteType free?
+
+CyteType is free for academic and non-commercial research within the usage limits. Personal accounts can run 10 jobs in any rolling 24 hours, 50 in 7 days and 100 in 30 days; failed jobs do not count. Commercial use requires a license: contact contact@nygen.io. See [Usage limits and quotas](../platform/limits.md).
+
+### How can I run more jobs?
+
+Email contact@nygen.io about a free academic license with higher limits. Organizations can get a license with a cluster quota shared by their members. Using your own language model does not raise the limits.
+
+### Under which licenses are the clients released?
+
+Both the Python package and the R package are released under the BSD 3-Clause License. Use of the hosted service is covered by separate terms. See [Licenses](citing.md#citing-licenses).
+
+(faq-support)=
+## Support
+
+### My job is taking a long time. Is it stuck?
+
+Open the report link printed when the job started (or the job in the [dashboard](../platform/dashboard.md)) to see its progress. The job keeps running on the server even if your session disconnects or the client stops waiting. Fetch the results later with `annotator.get_results()` in Python, or `GetResults(job_id = "")` in R. A job that runs for more than 4 hours is stopped and marked as failed. See [Run, monitor and rerun jobs](../guides/running-jobs.md).
+
+### Where can I get help?
+
+Start with [Troubleshooting](troubleshooting.md). You can also open an issue on GitHub for [CyteType](https://github.com/NygenAnalytics/CyteType/issues) or [CyteTypeR](https://github.com/NygenAnalytics/CyteTypeR/issues), email contact@nygen.io, or ask on [Discord](https://discord.gg/V6QFM4AN).
+
+### How do I cite CyteType?
+
+Cite the bioRxiv preprint by Ahuja et al. (2025) and report the client version, the run date and the job ID. [Cite CyteType and report versions](citing.md) has the reference, BibTeX and a checklist.
diff --git a/docs/source/support/security-and-privacy.md b/docs/source/support/security-and-privacy.md
new file mode 100644
index 0000000..2f7faa8
--- /dev/null
+++ b/docs/source/support/security-and-privacy.md
@@ -0,0 +1,156 @@
+---
+description: What the CyteType clients upload, how CyteType protects and uses your data, who can see your reports, how deletion and retention work, and what to check before you upload sensitive data.
+---
+
+(security-and-privacy)=
+# Security and privacy
+
+CyteType prepares your data on your computer with the Python or R client and runs the annotation on a hosted service. This page describes what leaves your computer, how it is protected and used, who can see your results, and what to check before you upload sensitive data. If your institution has contractual, regulatory or model-provider requirements, contact contact@nygen.io before you upload any data.
+
+## At a glance
+
+- New reports are private. The owner decides whether to share a report with specific people, with the organization or publicly.
+- Data is encrypted in transit (HTTPS with TLS) and encrypted at rest by the infrastructure providers that store it.
+- Your scientific data is not used to train or benchmark models.
+- You sign in with a one-time code sent to your email. The clients use API keys that you can disable at any time.
+- You can run the annotation with your own model provider.
+- Owners can delete reports. Deletion removes all access but keeps the underlying data (see [Deletion and retention](#security-and-privacy-deletion)).
+- A data processing agreement (DPA) is available with a commercial license.
+
+(security-and-privacy-uploads)=
+## What leaves your computer
+
+A job sends a request and uploads two files.
+
+### The job request
+
+- Your study context and the metadata tags you pass as `metadata`.
+- Your cluster labels and the top marker genes of each cluster.
+- For every gene, the percentage of cells in each cluster that express it.
+- Per-cluster summaries of your categorical cell metadata: the values found in more than `min_percentage` of a cluster's cells. Turn them off with `aggregate_metadata=False` (R: `aggregate_metadata = FALSE`).
+- Embedding coordinates of up to `max_cells_per_group` sampled cells per cluster.
+- Settings such as `n_parallel_clusters` and the report visibility, and the name and version of the client.
+- If you use your own model: your `llm_configs`, including the provider API key.
+
+The client also saves the request in `query.json` in your working directory. The Python client never writes `llm_configs` to that file. CyteTypeR does, including your provider keys, so pass `save_query = FALSE` when you use `llm_configs` in R.
+
+### The two files
+
+- **`vars.h5`:** the normalized expression matrix for all cells and genes (`adata.X`; in R, the normalized data of the `RNA` assay), the gene metadata table (`adata.var`; in R, the assay's feature metadata), and raw counts when the client finds them (Python: whole-number counts in `adata.layers["counts"]`, `adata.raw.X` or `adata.X`; R: the `counts` layer of the `RNA` assay, when it holds whole numbers).
+- **`obs.duckdb`:** the complete cell metadata table, every column of `adata.obs` (R: `meta.data`), and the 2D embedding coordinates of all cells.
+
+### What is not uploaded
+
+FASTQ or BAM files, and your AnnData or Seurat object as a whole, are not uploaded. Neither are other layers or assays, other embeddings, neighbor graphs, or unstructured data such as `adata.uns`, apart from the marker genes the client takes from your marker results.
+
+The CyteType files still contain your expression data and all of your cell metadata, so treat them with the same care as the source data. Some settings do less than their names suggest:
+
+- `aggregate_metadata=False` removes the metadata summaries from the request, but `obs.duckdb` still contains every metadata column.
+- `require_artifacts=False` only lets a run continue when a file fails to build or upload. It is not a way to avoid uploading: when the files build, they are uploaded.
+- There is no setting that sends only the summaries. To limit what is uploaded, remove columns from a copy of your object before you prepare it.
+
+The files stay on your computer after the run until you delete them: `annotator.cleanup()` (R: `CleanUpArtifacts(prepped_data)`) removes `vars.h5` and `obs.duckdb`. Delete `query.json` yourself if you do not want to keep it.
+
+### Other connections the clients make
+
+- Browser sign-in opens CyteType pages in your browser and uses a temporary listener on `127.0.0.1` on your own computer.
+- When you import the Python package or run the `cytetype` command, it asks PyPI whether a newer version exists. CyteTypeR asks GitHub when it is loaded. These checks send no information about your data or analysis.
+
+## How your data is used
+
+CyteType uses your data to run your jobs, to host your reports, and for the features you use on them, such as [Chat](../platform/chat.md), the [MCP connector](../platform/mcp.md) and [target analysis](../platform/target-analysis.md). Your scientific data is not used to train or benchmark models.
+
+CyteType also collects operational telemetry to run, secure, support and improve the service: service health, performance, errors and how the product is used. When a job fails, diagnostic records can include information about that job.
+
+Authorized Nygen staff may access service data when this is needed to operate, secure, support or troubleshoot CyteType.
+
+(security-and-privacy-services)=
+## Models and other services
+
+The annotation workflow sends focused context to language models, not your files: the study context, marker genes, expression percentages, metadata summaries, candidate annotations and the reasoning produced along the way. Tools in the workflow can look up values in your uploaded files, and those values can then be part of a prompt. The complete `vars.h5` and `obs.duckdb` files are not sent to model providers.
+
+Unless you bring your own model, CyteType runs the models it hosts through commercial model providers. Which provider serves a step can vary with the workflow step and with availability.
+
+Other services that process data for CyteType:
+
+- **Infrastructure:** cloud providers for sign-in and account records, computing and storage, and file transfer. They encrypt the data they store.
+- **Scientific resources:** workflow tools query public gene-set, ontology and cell-marker databases (and, for target analysis, a public drug-target knowledge base) with focused terms such as gene names, library names or ontology terms, never with your files.
+- **Operations:** separate services for product analytics, application monitoring, email delivery and checking email addresses at sign-in. They receive only the account, usage or diagnostic information they need.
+
+(security-and-privacy-own-llm)=
+## Your own model provider
+
+When you pass `llm_configs`, the annotation agents of that job use only the models you configure, under your own agreement with that provider. Keep in mind:
+
+- Your provider key and the prompts pass through CyteType's service, which calls your provider for you. Use a key made for CyteType, with a spending limit if your provider offers one, and disable it when you no longer need it.
+- Your files are still uploaded to CyteType, and CyteType still hosts the report.
+- [Chat](../platform/chat.md) always uses CyteType's hosted models, also for jobs annotated with your own model.
+- How long your provider keeps prompts, and whether it trains on them, depends on your agreement and settings with that provider.
+- A ChatGPT Enterprise workspace is separate from OpenAI API access. You need an API key for the provider's API.
+
+See [Use your own LLM](../guides/llm-configuration.md).
+
+(security-and-privacy-access)=
+## Access controls
+
+- **Sign-in:** the dashboard, reports and Chat use a one-time code sent to your email; there are no passwords. Browser sessions use secure, HTTP-only cookies that page scripts cannot read.
+- **Command-line sign-in:** `cytetype setup` and `SetupCyteTypeR()` ask you to confirm in the browser every time (**Authorize CLI**), even when you are already signed in. The key is handed to a temporary listener on your own computer with a one-time code that only that listener can exchange, and it never appears in a URL or in the terminal.
+- **API keys:** personal keys start with `cyt_p_` and organization keys with `cyt_`. A key created in the dashboard is shown once, when it is created; browser sign-in (`cytetype setup`, `SetupCyteTypeR()`) gives each computer you sign in from the same "CyteType CLI" key (see [Sign in and API keys](../getting-started/sign-in.md)). Keys do not expire, but you can disable a key at once in the [dashboard](../platform/dashboard.md). `cytetype logout` and `LogoutCyteTypeR()` only delete the copy saved on your computer, where the file is readable only by you on Linux and macOS.
+- **Report access:** when you open a report, the server checks ownership, sharing and visibility, then gives your browser a signed, short-lived access token that works only for that report.
+- **On the server:** every request for results is checked against the job's owner, shares and visibility. Database connections use TLS, and the service uses rate limits and monitoring.
+
+(security-and-privacy-visibility)=
+## Who can see your results
+
+| Visibility | Who has access |
+|---|---|
+| Private (the default) | You, and the people you share the report with by email. A recipient signs in with that email address to open it. |
+| Organization | Also all members of your organization. Only for reports submitted with an organization API key. |
+| Public | Anyone with the link can open the report page, without signing in. In addition, any signed-in CyteType user can find the report in Chat's report search, use it in [Chat](../platform/chat.md) and the [MCP connector](../platform/mcp.md), and fetch its results with their own API key. |
+
+Keep in mind:
+
+- People who have access to a job in Chat or through the MCP connector can query its uploaded cell-level data, including individual rows of the cell metadata table with every column you uploaded. For a public report, that means any signed-in user.
+- Anyone who can open a report can also read the results of the job's target analysis, although the report page does not display them.
+- The MCP connector always requires sign-in. When you open results in an AI assistant through it, they are sent to that assistant under your agreement with its provider.
+- Organization admins have no extra access to other members' private reports.
+- The owner can change the visibility in the dashboard at any time, in both directions. That cannot recall copies that people already made, such as CSV downloads.
+
+Before you make a report public, check that you may publish everything it exposes. [Visibility and sharing](../platform/sharing.md) lists exactly what a public link exposes, and which API keys can fetch a job's results.
+
+(security-and-privacy-deletion)=
+## Deletion and retention
+
+Only the owner can delete a report, from the [dashboard](../platform/dashboard.md). Deletion cannot be undone from the dashboard.
+
+After deletion:
+
+- nobody can open the report, including you: the link shows that the owner deleted it;
+- all shares and pending invitations are removed;
+- requests for its results with an API key fail with the error code `REPORT_DELETED`;
+- Chat and the MCP connector can no longer use it.
+
+Deletion is a soft delete: the job's underlying data is kept on CyteType's storage and is not erased. Deleted jobs still count toward your usage limits or your organization's quota. If you delete a job that is still running, it disappears from your views but runs to the end.
+
+Archiving is different: it can be reversed and does not change who can open the report. It only hides the report from your dashboard list, and from the report search in Chat and the job list of the MCP connector.
+
+**Retention.** CyteType does not delete reports or job data automatically after a period of time. Uploaded files that never become part of a job expire after 24 hours and are then deleted. If you need data erased from CyteType's storage, or your institution requires a specific deletion procedure, contact contact@nygen.io before you upload data.
+
+(security-and-privacy-sensitive)=
+## Sensitive and regulated data
+
+:::{important}
+CyteType does not detect or remove personal identifiers or protected health information. Complete your institution's review before you upload confidential human data.
+:::
+
+Before you upload confidential human data:
+
+1. Work on a reviewed copy of the AnnData or Seurat object.
+2. Remove direct identifiers, and any cell metadata columns that you do not need, from that copy. Every remaining column is uploaded in `obs.duckdb`.
+3. Keep the study context, metadata tags and cluster labels free of names, record numbers and other identifying text.
+4. Confirm the transfer with your institution's data protection, ethics or security team.
+5. Contact contact@nygen.io before you start if you need a DPA, a specific model provider, or another access or deployment arrangement.
+
+## Security reviews and agreements
+
+Customers with a commercial license can request a data processing agreement. Nygen can also answer security questionnaires and discuss requirements for model providers, access and deployment. Contact contact@nygen.io, ideally before you upload data that falls under a formal institutional review.
diff --git a/docs/source/support/troubleshooting.md b/docs/source/support/troubleshooting.md
new file mode 100644
index 0000000..0865523
--- /dev/null
+++ b/docs/source/support/troubleshooting.md
@@ -0,0 +1,519 @@
+---
+description: Fixes for common CyteType problems with signing in, preparing data, building and uploading files, running jobs and installing the clients, from the message you see to the cause and the fix.
+---
+
+(troubleshooting)=
+# Troubleshooting
+
+Find the message or behavior you see, then read why it happens and how to fix it. For the meaning of each exception class and server error code, see [Errors and error codes](../reference/errors.md). If your problem is not listed, see [Get help](#troubleshooting-help).
+
+(troubleshooting-sign-in)=
+## Signing in and API keys
+
+### The browser does not open during setup
+
+**You see:** `Was not able to launch web browser` (R: `Could not launch a web browser.`), or nothing opens.
+
+**Why:** the command could not start a browser, for example on a computer without a desktop, or the browser started somewhere you cannot see it.
+
+**Fix:** keep the command running and copy the link printed under "If it does not open automatically, use this URL:" into a browser on the same computer. If the computer has no browser, see [Sign in on a remote machine](#troubleshooting-remote).
+
+### Signing in from WSL
+
+On Windows Subsystem for Linux, the clients open your Windows default browser. The browser then returns the key to the command through `127.0.0.1` inside WSL, which Windows reaches through WSL's localhost forwarding (on by default).
+
+- If no browser opens, copy the printed link into a browser in Windows.
+- If the browser cannot connect to `127.0.0.1` after you select **Authorize CLI**, localhost forwarding is probably turned off in your WSL configuration. Turn it back on, or save a key from the dashboard with `cytetype login` (R: `LoginCyteTypeR()`).
+
+(troubleshooting-remote)=
+### Sign in on a remote machine (SSH, HPC, remote notebook)
+
+**Why:** browser sign-in hands the key back to a temporary listener at `127.0.0.1` on the machine that runs the command. A browser on your laptop cannot reach that address on a remote server.
+
+**Fix:** create an API key on the **API keys** page of the dashboard, then save it on the remote machine with `cytetype login` (R: `LoginCyteTypeR()`), or pass it to `run()` or `CyteTypeR()` as `auth_token`. [Sign in and API keys](../getting-started/sign-in.md#sign-in-remote) has the steps.
+
+- `cytetype login` asks for the key in a terminal. A notebook has no terminal to type into, so open one (in JupyterLab: **File**, **New**, **Terminal**), or pass the key in code, for example `auth_token=getpass.getpass("API key: ")`.
+- If you prefer the browser sign-in over SSH, you can forward the listener's port. Run `cytetype setup` on the server and note the port: the number after `127.0.0.1%3A` in the printed link. In a second terminal on your own computer, run `ssh -N -L :127.0.0.1: @`, then open the printed link in your local browser within 5 minutes.
+
+### Setup waits and stops with "CyteType setup timed out"
+
+**Why:** the browser step was not finished within 5 minutes, or the browser could not deliver the result to the listener. Security software, a firewall or a browser policy that blocks `http://127.0.0.1` has this effect, and so does a browser running on another computer.
+
+**Fix:** run the command again, keep it running, and finish in a browser on the same computer within 5 minutes. If loopback addresses are blocked on your computer, ask your IT team to allow `127.0.0.1`, or save a key from the dashboard with `cytetype login` (R: `LoginCyteTypeR()`).
+
+### "Please use your university/institute/company email"
+
+**Why:** CyteType accounts need an institutional email address. Addresses from personal email providers (such as Gmail or Outlook) are not accepted.
+
+**Fix:** sign in with your university, institute or company address. If your institutional address is refused, contact contact@nygen.io.
+
+### "CyteType sign-in is required" or "No saved CyteType sign-in found"
+
+**Why:** no API key is saved for the server the client is using. Either you have not signed in on this computer as this user, or the saved sign-in belongs to a different server: it is tied to one server, and your `api_url` or `CYTETYPE_API_URL` points to another. A batch job or container that runs as another user, or with another home directory or `XDG_CONFIG_HOME`, does not see your saved key either.
+
+**Fix:**
+
+- Sign in for the server you use in your code: `cytetype setup`, or `cytetype setup --api-url 
` for another server (R: `SetupCyteTypeR()`). +- If you set `CYTETYPE_API_URL` by mistake, unset it. In Python, an empty `CYTETYPE_API_URL` is an error too. +- In batch jobs and containers, pass the key as `auth_token` instead. + +In Python, the note printed by `CyteType()` names the server it looked for, and `annotator.api_url` shows the server in use. + +### "Invalid CyteType credentials file" + +**Why:** the saved `credentials.json` is damaged or incomplete, for example after a manual edit or a full disk. + +**Fix:** delete it and sign in again. + +::::{tab-set} +:::{tab-item} Python +:sync: python +```bash +cytetype logout +cytetype setup +``` + +`cytetype setup --force` also works: it does not read the old file. +::: +:::{tab-item} R +:sync: r +```r +LogoutCyteTypeR() +SetupCyteTypeR() +``` + +`SetupCyteTypeR(force = TRUE)` also works: it does not read the old file. +::: +:::: + +### "Saved CyteType credentials could not be read" + +**Why (Python):** the client could not reach its configuration folder, for example in a container or batch job without a home directory, or with a home directory you cannot read. + +**Fix:** pass the key as `auth_token`, or set `XDG_CONFIG_HOME` to a readable folder that contains `cytetype/credentials.json`. + +### The saved key is rejected + +**You see:** `Invalid API token` or `API token has expired or been deactivated`. + +**Why:** the key was disabled in the dashboard, by you or, for an organization key, by an admin. All computers that signed in with browser sign-in share your "CyteType CLI" key, so disabling it on one signs out all of them. A key from another CyteType server is also rejected. + +::::{tab-set} +:::{tab-item} Python +:sync: python +`cytetype setup` stops with "... Run `cytetype setup --force` to re-authenticate with a new key". `run()` raises `AuthenticationError` with "Artifact upload was rejected: ...". Get a new key: + +```bash +cytetype setup --force +``` + +Or save another active key with `cytetype login`. `run()` reads the saved key every time, so you can call it again on the same `CyteType` object without preparing the data again. +::: +:::{tab-item} R +:sync: r +`SetupCyteTypeR()` stops with "... Run SetupCyteTypeR(force = TRUE) or `cytetyper setup --force` to re-authenticate with a new key". `CyteTypeR()` stops with "Upload initiate failed: [TOKEN_INACTIVE] API token has expired or been deactivated". Get a new key: + +```r +SetupCyteTypeR(force = TRUE) +``` + +Or save another active key with `LoginCyteTypeR()`. Then run `CyteTypeR()` again with the same `prepped_data`. +::: +:::: + +### "CyteType credentials directory is not owned by the current user" + +**Why:** the credentials folder was created by another user, for example by running `sudo cytetype setup`, or `XDG_CONFIG_HOME` points to a shared folder. + +**Fix:** give the folder back to your user (for example `sudo chown -R "$USER" ~/.config/cytetype`), or set `XDG_CONFIG_HOME` to a folder of your own. + +### The `cytetype` or `cytetyper` command is not found + +- **Python:** activate the environment where `cytetype` is installed, or run the commands through Python, for example `python -m cytetype.cli setup`. If `python -m cytetype.cli` fails too, you may have an old release; see [An old version is installed](#troubleshooting-old-version). +- **R:** install the launcher with `InstallCyteTypeRCli()`. It goes into the folder of `Rscript`, which must be on your `PATH`. The launcher starts R with `--vanilla`, so it ignores `.Rprofile` and `.Renviron`; if CyteTypeR is installed only in a project library such as renv, it stops with `Error: Unable to load CyteTypeR`. You can always use the R functions instead (`SetupCyteTypeR()` and so on). See [the `cytetyper` command](../getting-started/installation.md#installation-cytetyper). + +### The server cannot be reached + +**You see:** `Could not validate the saved API key`, `NetworkError: Connection failed: ...`, or uploads that fail with connection errors. + +**Why:** the machine has no connection to `https://cytetype.nygen.io`. Typical cases are compute nodes without internet access, firewalls, and networks that require a proxy. + +**Fix:** run CyteType on a machine with outbound HTTPS access. If your network uses a proxy, set the standard `HTTPS_PROXY` environment variable; both clients use it. + +(troubleshooting-data)= +## Preparing your data + +### "Gene Symbol Detection Error" + +**Why (Python):** about half or more of the gene names that `CyteType()` selected look like database IDs (Ensembl, RefSeq, Entrez or similar accession numbers). CyteType needs readable gene symbols, such as `CD3E`. + +**Fix:** pass `gene_symbols_column` with the name of the `adata.var` column that holds symbols, or convert the IDs to symbols first. Without it, CyteType looks for symbols in the columns `feature_name`, `gene_symbols`, `gene_symbol`, `gene_short_name`, `gene_name` and `symbol`, then in `adata.var_names`, then in the other columns. Values that combine a symbol and an ID, such as `TSPAN6_ENSG00000000003`, are split automatically. A `KeyError` "Column '...' not found in `adata.var`" means the name you passed does not exist. + +**In R:** CyteTypeR only warns and continues, which makes the annotation less reliable: `More than half of the gene values (...%) appear to be gene IDs` for the gene names of the `RNA` assay, or `...% of the genes in the marker table look like gene IDs`. Use gene symbols as the row names of the Seurat object before you compute markers. See [Prepare your data](../guides/prepare-data.md#prepare-data-gene-symbols). + +### "`rank_genes_groups` run with groupby='...', expected '...'" + +**Why:** the marker genes stored under `rank_key` were computed for another cluster column than `group_key`. + +**Fix:** compute markers for the same column and pass their key: + +```python +sc.tl.rank_genes_groups(adata, groupby="leiden", key_added="rank_genes_leiden") +annotator = CyteType(adata, group_key="leiden", rank_key="rank_genes_leiden") +``` + +A `KeyError` "'rank_genes_groups' not found in `adata.uns`" means no markers are stored under that key: run `sc.tl.rank_genes_groups` first, or pass the right `rank_key`. + +### Marker genes are lost + +**You see:** warnings "Number of lost genes (...) for group ..." or the error "All marker gene lists are empty. Gene names in rank_genes_groups could not be matched to adata.var_names." + +**Why:** the marker genes do not match `adata.var_names`. Usually `var_names` were changed after the markers were computed (for example from Ensembl IDs to symbols), or the markers were ranked on `adata.raw` (Scanpy's default when `adata.raw` is set), which holds genes that are no longer in `adata.var`, for example after subsetting to highly variable genes. + +**Fix:** compute the markers again on the same AnnData object you pass to `CyteType()`, after any change to its genes (with `use_raw=False` if `adata.raw` holds other genes). + +### "Internal inconsistency: Group name '...' from rank_genes_groups results was not found" + +**Why:** the clusters changed after the markers were computed: you clustered again into the same column, renamed clusters, or removed cells so that a cluster no longer exists. + +**Fix:** compute the markers again after changing the clusters. + +### Cells without a cluster label + +**You see (Python):** `... cells (...%) have NaN values in 'leiden'. Either fix the data or set label_na=True ...` + +**You see (R):** `... cells (...%) have NA values in 'seurat_clusters'. Assign these cells to a cluster, or remove them (for example with subset()), before calling PrepareCyteTypeR().` + +**Why:** some cells have no cluster label, for example after merging objects or transferring labels. + +::::{tab-set} +:::{tab-item} Python +:sync: python +Give those cells a label, remove them, or let CyteType put them in a cluster named "Unknown" (or "Unknown 2" if "Unknown" exists): + +```python +annotator = CyteType(adata, group_key="leiden", label_na=True) +adata = annotator.run(study_context="...") +``` + +With `label_na=True`, `run()` returns a new AnnData object, and only that object holds the results. Always assign the return value. +::: +:::{tab-item} R +:sync: r +CyteTypeR has no `label_na` option. Remove the cells without a label, or give them a label, before `FindAllMarkers()` and `PrepareCyteTypeR()`: + +```r +pbmc <- subset(pbmc, cells = colnames(pbmc)[!is.na(pbmc$seurat_clusters)]) +``` +::: +:::: + +### No embedding in the report + +**You see (Python):** `No suitable 2D coordinates found in adata.obsm ... Visualization will be disabled.` (R: `No suitable 2D coordinates found in the object's reductions. ... Visualization will be disabled.`) + +**Why:** the client found no 2D embedding. Python tries `coordinates_key`, then `X_umap`, `X_tsne` and `X_pca` in `adata.obsm` (in any capitalization) and needs one row per cell and at least 2 columns. R tries `coordinates_key`, then `umap`, `tsne` and `pca` among the object's reductions, in the same way. + +**Fix:** the job still runs, but the report has no embedding plot. Compute a UMAP before preparing the data (`sc.tl.umap(adata)`, R: `RunUMAP()`), or pass the name of the embedding you have as `coordinates_key` (R: one of `names(pbmc@reductions)`). + +### "No integer raw counts found" + +**You see (Python):** `No integer raw counts found in adata.layers['counts'], adata.raw.X, or adata.X. Skipping raw counts in vars.h5.` (R: `The raw counts are not integer-valued. Skipping raw counts in vars.h5.`) + +**Why:** `CyteType()` includes raw counts in `vars.h5` when it finds a matrix of whole numbers in `adata.layers["counts"]`, then `adata.raw.X` (only if it contains all of `adata.var_names`), then `adata.X`. It found none. CyteTypeR includes the `counts` layer of the `RNA` assay only when every value in it is a whole number. + +**Fix:** annotation works without counts, but features that need them, such as [target analysis](../platform/target-analysis.md), are not available for the job. To include counts, keep a copy before you normalize: + +```python +adata.layers["counts"] = adata.X.copy() # before sc.pp.normalize_total and sc.pp.log1p +``` + +In R, create the Seurat object from the raw counts with `CreateSeuratObject(counts = ...)`; `NormalizeData()` keeps them in the `counts` layer. + +(troubleshooting-artifacts)= +## Building and uploading files + +`CyteType()` (R: `PrepareCyteTypeR()`) writes two files, `vars.h5` (expression) and `obs.duckdb` (cell metadata and coordinates), and `run()` (R: `CyteTypeR()`) uploads them before it submits the job. [How CyteType works](../getting-started/how-cytetype-works.md) explains what they are used for. + +### A file could not be built or uploaded + +**You see (Python):** a warning such as `vars.h5 artifact failed during build: ...` when you create `CyteType`, or `obs.duckdb upload failed: ...`, then `Artifact build/upload failed for: ... Rerun with require_artifacts=False to skip this error.` and the original error from `run()`. + +**You see (R):** `Error building artifacts: ...` from `PrepareCyteTypeR()` and `Artifact build did not succeed. Set require_artifacts = FALSE to continue without artifacts.` from `CyteTypeR()`, or `Uploading artifacts failed: ...`. + +**Why:** with the default `require_artifacts=True` (R: `TRUE`), any problem building or uploading the files stops the run, so that you do not get a report with features missing without noticing. + +**Fix:** read the original error at the end of the message; the sections below cover the common causes. If you need the annotation now, run with `require_artifacts=False` (R: `require_artifacts = FALSE`). The job then runs without the failed file; in Python, a file that was built and uploaded is still used, while CyteTypeR runs without either file when one of them fails. Without the files, the evidence that CyteType computes from your cells is missing from the report, Chat has less data to work with, and target analysis is not available (see [`require_artifacts`](../guides/running-jobs.md#running-jobs-options)). In Python, a rejected API key or a refused account stops the run even with `require_artifacts=False`. If a failure looks like a bug, please report it on GitHub (see [Get help](#troubleshooting-help)). + +### "Failed to cast value: Could not convert string ..." + +**Why (Python):** a column of `adata.obs` with the generic `object` type mixes numbers and text, for example donor ages with one `"unknown"`. The database file infers the column type (such as `INT32` or `DOUBLE`) from the first rows and fails when the text appears, so `obs.duckdb` is not built. + +**Fix:** convert such columns to text before you create `CyteType`: + +```python +adata.obs["donor_age"] = adata.obs["donor_age"].astype(str) +``` + +### Full disk, read-only folder or no temporary space + +**You see:** `[Errno 28] No space left on device`, `Permission denied` or `Read-only file system` while the files are built, or when `query.json` is saved. + +**Why:** the files are written to the current working directory by default, and `vars.h5` can be large: it holds the whole normalized matrix and, when found, the raw counts. Building `obs.duckdb` can also write temporary files. `run()` saves `query.json` in the working directory after the upload and before the job is submitted. + +::::{tab-set} +:::{tab-item} Python +:sync: python +Write the files to a disk with space, and remove them after the run: + +```python +annotator = CyteType( + adata, + group_key="leiden", + vars_h5_path="/scratch/me/vars.h5", + obs_duckdb_path="/scratch/me/obs.duckdb", +) +adata = annotator.run( + study_context="...", + query_filename="/scratch/me/query.json", # or save_query=False +) +annotator.cleanup() # deletes vars.h5 and obs.duckdb +``` + +Temporary files for `obs.duckdb` go to the system temporary folder. Set the `TMPDIR` environment variable before you start Python to use another folder. +::: +:::{tab-item} R +:sync: r +Write the files to a disk with space, and remove them after the run: + +```r +prepped_data <- PrepareCyteTypeR( + pbmc, + pbmc.markers, + vars_h5_path = "/scratch/me/vars.h5", + obs_duckdb_path = "/scratch/me/obs.duckdb" +) +pbmc <- CyteTypeR( + obj = pbmc, + prepped_data = prepped_data, + study_context = "...", + query_filename = "/scratch/me/query.json" # or save_query = FALSE +) +CleanUpArtifacts(prepped_data) # deletes vars.h5 and obs.duckdb +``` + +Temporary files for `obs.duckdb` go to R's temporary folder (`tempdir()`). Set the `TMPDIR` environment variable before you start R to use another folder. +::: +:::: + +### Running out of memory with large data + +**Why:** when `adata.X` is held in memory, building `vars.h5` needs the whole matrix plus working copies in memory. `CyteType()` prints `For large datasets, use AnnData backed mode ...` whenever `adata.X` is in memory; for small datasets you can ignore this. + +**Fix (Python):** open the file in backed mode, `sc.read_h5ad("data.h5ad", backed="r")`, compute markers with `cytetype.rank_genes_groups_backed()`, and lower `pcent_batch_size` if needed. CyteTypeR has no backed mode: give R enough memory for the object. See [Large datasets](../guides/large-datasets.md). + +### A file is larger than the upload limit + +**You see:** `obs_duckdb exceeds upload limit: ...` or `vars_h5 exceeds upload limit: ...`. + +**Why:** `obs.duckdb` can be up to 2 GiB and `vars.h5` up to 50 GiB (see [Usage limits and quotas](../platform/limits.md)). + +**Fix:** `obs.duckdb` holds every column of `adata.obs` (R: `meta.data`), so drop columns you do not need, especially long per-cell text. For `vars.h5`, reduce the data, for example by subsampling cells per cluster; see [Large datasets](../guides/large-datasets.md). + +### Uploads are slow or fail part-way + +Files are uploaded in parts, several at a time: up to `upload_max_workers` parts (Python default 4, R default 6). Both clients retry a part that fails with a network error, a timeout or a server error up to three times, after 1, 5 and 20 seconds; Python logs each retry as `Chunk ... upload failed (attempt .../4), retrying in ...`. + +- On a slow or unstable connection, lower `upload_max_workers` and raise `upload_timeout_seconds`, the time allowed for each request (default 3600 seconds). +- In R, parts are uploaded in parallel only when the furrr package is installed, and also progressr unless you pass `show_progress = FALSE`; otherwise one at a time. +- `Presigned URL upload rejected (HTTP 403)` (R: `Presigned upload rejected with HTTP 403`) means the file storage service rejected a part, for example when an upload has been running for many hours. Run again, ideally from a faster connection. + +### "The artifact files were deleted by cleanup()" + +**Why (Python):** `cleanup()` deleted `vars.h5` and `obs.duckdb`, and `run()` needs them. + +**Fix:** create a new `CyteType` object to build them again, or pass `require_artifacts=False` to run without them. + +(troubleshooting-jobs)= +## Submitting and running jobs + +### `metadata` values are rejected + +::::{tab-set} +:::{tab-item} Python +:sync: python +**You see:** `pydantic.ValidationError` ... `infoTags. Input should be a valid string`. + +Every value in `metadata` must be a string: + +```python +metadata = {"n_donors": 4, "GEO": "GSE123456"} +adata = annotator.run( + study_context="...", + metadata={key: str(value) for key, value in metadata.items()}, +) +``` +::: +:::{tab-item} R +:sync: r +**You see:** `All infoTags values must be character strings`. + +Every value in `metadata` must be a character string: + +```r +metadata <- list(n_donors = 4, GEO = "GSE123456") +pbmc <- CyteTypeR( + obj = pbmc, + prepped_data = prepped_data, + study_context = "...", + metadata = lapply(metadata, as.character) +) +``` +::: +:::: + +### "Results with prefix 'cytetype' already exist" + +**Why:** the object already holds a CyteType job under this `results_prefix`. In Python, the job ID is stored as soon as the job is submitted, so a run that later timed out, failed or was interrupted counts too. + +**Fix:** to get the results of that job, call `annotator.get_results()` (R: `GetResults(pbmc)`). To keep both runs, pass a new prefix such as `results_prefix="cytetype_v2"`. To replace the old results, pass `override_existing_results=True` (R: `override_existing_results = TRUE`). + +### `llm_configs` is rejected + +- **Before upload (Python):** a `pydantic.ValidationError` such as "Either apiKey or all AWS credentials must be provided", or a provider name that is not in the list. **R:** "provider must be one of: ..." or "Either apiKey or all AWS credentials must be provided". +- **At submission:** `LLMValidationError` (R: "Job submission failed: [LLM_VALIDATION_FAILED] ..."). A model could not be reached or does not support tool calling. See [LLM_VALIDATION_FAILED](../reference/errors.md#errors-llm-validation) and [Use your own LLM](../guides/llm-configuration.md). + +### Job limit reached + +**You see:** `RateLimitError` with a message such as `Daily limit reached (10/10). Resets in about 5 hours. ...` (R: the same message, then "The limits apply to every job, including jobs that use your own LLM through llm_configs. Wait for the limit to reset before retrying."). + +**Why:** personal accounts can run 10 jobs in any rolling 24 hours, 50 in 7 days and 100 in 30 days. The limit is checked when the job is submitted, after the files are uploaded. The limits also apply when you use your own LLM. + +**Fix:** wait until the time in the message, or email contact@nygen.io about a free academic license with higher limits. See [Usage limits and quotas](../platform/limits.md). + +### Quota exceeded or license expired + +`QuotaExceededError` and `AuthorizationError` with `LICENSE_EXPIRED` concern organization licenses and per-key limits. An expired license stops the run as soon as the files start to upload; a used-up quota is detected when the job is submitted, after the upload. See [QUOTA_EXCEEDED](../reference/errors.md#errors-quota-exceeded), [LICENSE_EXPIRED](../reference/errors.md#errors-license-expired) and [Usage limits and quotas](../platform/limits.md). + +### "Org visibility requires an organization license" + +**You see (Python):** before the upload, the warning `visibility='org' needs an organization API key, and this key looks like a personal key (cyt_p_...). If it is, the server rejects the job after the upload. Use an organization key, or choose 'private' or 'public'.` After the upload, `run()` raises an `APIError` with the code `HTTP_ERROR`: "Org visibility requires an organization license". + +**You see (R):** before the upload, the warning `visibility = "org" needs an organization API key, and this key looks like a personal key (cyt_p_...). If it is, the server rejects the job after the upload. Use an organization key, or choose "private" or "public".` After the upload, `Job submission failed: [HTTP_ERROR] Org visibility requires an organization license`. + +**Why:** you passed `visibility="org"` (R: `visibility = "org"`) with an API key that does not belong to an organization license. Only organization API keys (starting with `cyt_`, created by an organization admin) can submit organization reports. A personal key (starting with `cyt_p_`), including the one that `cytetype setup` or `SetupCyteTypeR()` saves, is refused even if your account belongs to an organization. A few organization keys also start with `cyt_p_` by chance, so the clients only warn; the server decides when the job is submitted, after the files are uploaded. + +**Fix:** run `run()` (R: `CyteTypeR()`) again with an organization key as `auth_token`, or without `visibility` (the report is then private). The files are uploaded again. If you see the warning, you can interrupt the run instead of waiting for the upload and the refusal. See [Visibility and sharing](../platform/sharing.md). + +### The client stopped waiting, or your session ended + +The job keeps running on the server until it finishes (at most 4 hours) whatever happens to your session. Its report link, printed when the job starts, shows the progress. + +::::{tab-set} +:::{tab-item} Python +:sync: python +**You see:** `CyteTypeTimeoutError: Job ... did not complete within 7200s`. + +`run()` waits `timeout_seconds` (default 7200, 2 hours). The job ID is already stored in `adata.uns`, so in the same session you can fetch the results later from the same object: + +```python +results = annotator.get_results() # None while the job is still running +``` + +When the job has finished, `get_results()` adds the same columns to `adata.obs` as `run()`. It makes one check and does not wait, so call it again later if it returns `None`. Next time, pass a larger `timeout_seconds`. + +If the Python session ended but you saved the AnnData object after the job started (for example with `annotator.adata.write_h5ad(...)`), load it, create `CyteType` again with the same `group_key` and `rank_key` (and `label_na`, if you used it), and call `get_results()`; creating `CyteType` prepares the data again, which takes some time. You can always open the report from the dashboard or with `cytetype view `. See [Run, monitor and rerun jobs](../guides/running-jobs.md). +::: +:::{tab-item} R +:sync: r +**You see:** `Job did not finish within 7200 seconds (timeout_seconds). The job keeps running on the server; retrieve its results later with GetResults(job_id = "").` + +`CyteTypeR()` waits `timeout_seconds` (default 7200, 2 hours), then stops with an error of class `cytetype_timeout_error`. As when you interrupt it or the R session ends, the annotated object is not returned, and your original object holds no job details. Take the job ID from the message, from the line `CyteType job (id: ...) submitted.` or from the dashboard, then: + +- open the report with `ViewCyteTypeJob("")`; **Download as CSV** in the report gives a table whose `author_label` column holds your original cluster labels, so you can join it to `pbmc@meta.data`; +- or fetch the results table with `GetResults(job_id = "")`. It returns `NULL` with a message while the job is still running. Once the job has finished, it returns the table and also writes the raw results to `cytetypeR_results_.json` in the working directory. + +Next time, pass a larger `timeout_seconds`. See [Run, monitor and rerun jobs](../guides/running-jobs.md). +::: +:::: + +(troubleshooting-job-failed)= +### The job failed + +**You see:** `JobFailedError: Job ... failed` (R: `Server error: Job failed`). + +**Fix:** open the report link to see how far the job got, then run the job again; in Python, pass `override_existing_results=True` (or another `results_prefix`) to reuse the same object. Failed jobs do not count toward the personal job limits. If it fails again, contact support with the job ID. + +### `get_results()` returns `None` + +The log line before it says why: + +- `Job ... is still processing. Results not yet available.` (or `still pending`): try again later. +- `Job ... results not yet available (404).`: the server does not know this job ID. Check the ID and that the client uses the server the job ran on. +- `Job ... failed`: see [The job failed](#troubleshooting-job-failed). +- `No results found locally and no job details available for retrieval.`: this object has no job under this `results_prefix`. Check the prefix. +- `Failed to retrieve results for job ...: `: the reason is a server message, for example from [ACCESS_DENIED](../reference/errors.md#errors-access-denied) or `REPORT_DELETED` (the owner deleted the report). + +**In R,** `GetResults()` returns `NULL` with a message when the job is still pending or processing (`Job ... is still processing; its results are not available yet. ...`): try again later. It also returns `NULL` when the server does not know the job ID (`Job ... was not found on ...`): check the ID and the `api_url`. For a failed job or a deleted report, it stops with an error instead. + +(troubleshooting-r)= +## CyteTypeR + +### The marker table is rejected + +| Message | Why | Fix | +|---|---|---| +| `Please check if cluster labels are consistent between marker table and seurat obj!` | The `cluster` values of the marker table are not the same set as the values of `group_key`. Common causes: markers computed for another clustering; a filter that removed every marker of a cluster; cells removed after `FindAllMarkers()`. | Compute markers with `FindAllMarkers()` on the same object and clustering, and filter them so that every cluster keeps some. | +| `Invalid marker genes, some clusters have fewer than 5 markers` | After `n_top_genes`, some clusters have fewer than 5 markers, usually because of a strict filter such as `avg_log2FC > 1`. | Loosen the filter, or check those clusters. | +| An error about a missing `cluster`, `gene` or `avg_log2FC` column | The table lacks a required column. | Use the `FindAllMarkers()` output, or rename your columns to `cluster`, `gene` and `avg_log2FC`. | + +### Problems in CyteTypeR 0.5.1 and earlier + +In CyteTypeR 0.5.1 and earlier, you may see these messages and problems. Upgrade to CyteTypeR 0.6.0, which fixes them (see [Installation](../getting-started/installation.md)): + +- `Package 'duckdb' is required to build obs.duckdb`: duckdb is not installed with the package. +- `GetAssayData doesn't work for multiple layers in v5 assay`: the `RNA` assay of a Seurat v5 object is split into layers. +- `feature_names length (...) does not match matrix columns (...)`: the default assay is not `RNA`. +- `duplicate 'row.names' are not allowed` at the end of `CyteTypeR()`: two or more clusters have no result. +- `Error: API key is required` from `cytetyper login`. +- `CyteTypeR()` does not stop at `timeout_seconds`, the evidence columns of `cytetype_results` are empty, there is no reviewer confidence column, and the job limit message wrongly suggests that your own LLM avoids the limits. + +(troubleshooting-install)= +## Installation and startup + +(troubleshooting-old-version)= +### An old version is installed + +**You see:** `cytetype --version` or `cytetype.__version__` shows an old release, the `cytetype` command does not exist, or a job is refused with "Authentication is required. Upgrade to the latest CyteType or CyteTypeR and run setup, or provide a valid API token." + +**Why:** pip only offers releases that support the Python you run it with, and it does not warn you. On Python 3.11 it installs 0.12.0, the last release for that version, which predates the `cytetype` command and the saved sign-in. Older CyteTypeR versions likewise lack `SetupCyteTypeR()` and the other sign-in functions. + +**Fix:** check `python --version`, then install the current release into an environment with Python 3.12 or newer (`pip install -U cytetype`). For R, update CyteTypeR from GitHub. See [Installation](../getting-started/installation.md) and its [Python notes](../getting-started/installation.md#installation-python-notes). + +### Installing CyteTypeR fails + +The most common causes are an R version older than 4.2, which CyteTypeR requires, and building the Bioconductor package rhdf5 from source. See the [R notes](../getting-started/installation.md#installation-r-notes) on the installation page. + +### Importing the package pauses for a moment + +**Why:** `import cytetype` (and each `cytetype` command) asks PyPI whether a newer version exists, and `library(CyteTypeR)` asks GitHub. Each check waits at most about 2 seconds for an answer. On a machine without internet access, the import pauses until the check gives up, then continues silently. + +**Fix:** nothing is needed; the check does not affect your analysis. If a newer version exists, you see `A newer CyteType version is available on PyPI` (R: `A newer version of CyteTypeR is available`); see [Installation](../getting-started/installation.md) to update. + +(troubleshooting-help)= +## Get help + +If your problem is not listed here: + +- Check [Errors and error codes](../reference/errors.md) and the [FAQ](faq.md). +- Open an issue on GitHub: [CyteType](https://github.com/NygenAnalytics/CyteType/issues) for the Python client, [CyteTypeR](https://github.com/NygenAnalytics/CyteTypeR/issues) for the R package. +- Email contact@nygen.io (the address behind **Contact support** in the dashboard). +- Ask the community on [Discord](https://discord.gg/V6QFM4AN). + +Include: + +- the client and its version (`cytetype --version`, or `packageVersion("CyteTypeR")` in R) and your Python or R version and operating system; +- the full error message and traceback; +- the job ID or report link, if a job was submitted. + +Never include your API key, your `credentials.json` file, or a CyteTypeR `query.json` that contains LLM API keys. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md deleted file mode 100644 index 564cbff..0000000 --- a/docs/troubleshooting.md +++ /dev/null @@ -1,24 +0,0 @@ -# Troubleshooting - -## CLI and Authentication - -- If Python reports `CyteType sign-in is required`, run `cytetype setup`, complete browser authorization, and retry. -- If you already have an API key, run `cytetype login`. The key is entered through a hidden prompt and saved only after the server validates it. -- For a custom server, use the same origin in the CLI and Python. For example, run `cytetype setup --api-url https://cytetype.example.org` and pass `api_url="https://cytetype.example.org"` to `CyteType`. -- If the browser does not open during setup, copy the authorization URL printed in the terminal. Keep the command running while you authorize because it is waiting for a callback on `127.0.0.1`. -- If setup times out, retry and complete authorization within five minutes. Check whether a local firewall or browser policy is blocking the callback to `127.0.0.1`. -- If setup reports that the saved key is invalid or inactive, run `cytetype setup --force` to authenticate with a new key. -- If the credentials file is invalid or unreadable, run `cytetype setup --force` or remove it with `cytetype logout` before retrying setup. -- `cytetype logout` deletes only the local credentials. Revoke the API key from the dashboard if the server should stop accepting it. - -See [CLI and Authentication](./cli.md) for the full command reference and credential locations. - -## Annotation and Artifacts - -- Rate limit responses include retry information; wait or provide your own LLM. -- Verify preprocessing: clustering and `rank_genes_groups` must be present. -- Make sure you have valid gene symbols in the AnnData object and are passing the correct gene symbols column name to parameter `gene_symbols_column`. -- If you are using a custom LLM, make sure you have the correct API key and base URL. -- For large datasets, load AnnData in backed mode (`sc.read_h5ad(..., backed="r")`) to reduce memory use during artifact generation. -- `run()` creates `vars.h5` and `obs.duckdb` before annotation. Use `cleanup_artifacts=True` if you do not want to keep these local files. -- If artifact building or uploading fails, `run()` will raise an error by default. Set `require_artifacts=False` to skip artifacts and continue with annotation only. diff --git a/pyproject.toml b/pyproject.toml index fb6bcb0..5281d75 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,19 +10,18 @@ dependencies = [ "natsort~=8.4.0", "requests~=2.32.5", "pydantic~=2.12.5", - "session-info~=1.0.1", - "python-dotenv~=1.2.1", "duckdb>=1.4.4", "hdf5plugin>=6.0.0", ] authors = [ {name = "Parashar Dhapola", email = "parashar@nygen.io"} ] -license = {text = "CC BY-NC-SA 4.0"} +license = "BSD-3-Clause" keywords = ["bioinformatics", "single-cell", "RNA-seq", "annotation", "cell types"] [project.urls] Homepage = "https://github.com/NygenAnalytics/cytetype" +Documentation = "https://docs.nygen.io/CyteType/" Issues = "https://github.com/NygenAnalytics/cytetype/issues" Repository = "https://github.com/NygenAnalytics/cytetype" @@ -41,13 +40,32 @@ dev = [ "types-requests~=2.32.4.20260107", "pytest-cov>=7.0.0", ] +# Builds the MyST site and the Sphinx API reference (make -C docs html), and +# executes the documentation notebooks locally (make -C docs execute-docs). +docs = [ + "mystmd>=1.11.0", + "jupytext>=1.19.6", + "nbclient>=0.10", + "nbformat>=5.10", + "ipykernel>=7.2.0", + "myst-parser", + "sphinx", + "sphinx-autodoc-typehints", + "sphinx-book-theme", + "sphinx-copybutton", + "pyyaml", + # The Python quickstart preprocesses its example data with Scanpy and Leiden. + "scanpy[leiden]", + # Notebook widgets, so tqdm does not warn while executing pages. + "ipywidgets", +] [build-system] requires = ["setuptools>=80.9.0", "setuptools-scm>=8", "wheel"] build-backend = "setuptools.build_meta" -[tool.setuptools] -packages = {find = {}} +[tool.setuptools.packages.find] +include = ["cytetype*"] [tool.setuptools.package-data] cytetype = ["templates/*.html"] @@ -82,4 +100,14 @@ warn_unused_ignores = true ignore_missing_imports = false -exclude = ["^build/", "^dist/", "^.*\\.egg-info/", "^cytetype/_version\\.py$"] +# The documentation tooling in docs/ needs the docs dependency group; its tests +# check it instead. +exclude = ["^build/", "^dist/", "^.*\\.egg-info/", "^cytetype/_version\\.py$", "^docs/"] + +[[tool.mypy.overrides]] +module = ["docs.*"] +follow_imports = "skip" + +[tool.pytest.ini_options] +# The documentation tests import the tooling in docs/ from the repository root. +pythonpath = ["."] diff --git a/tests/conftest.py b/tests/conftest.py index 004cf49..b0e6ce6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,7 +5,13 @@ import numpy as np import pandas as pd import scanpy as sc -from typing import Any +from collections.abc import Iterator +from typing import TYPE_CHECKING, Any + +from cytetype.config import logger + +if TYPE_CHECKING: + from loguru import Message @pytest.fixture @@ -102,3 +108,17 @@ def mock_api_response() -> dict[str, Any]: "clusterCategories": [], "studyContext": "Test study", } + + +@pytest.fixture +def log_messages() -> Iterator[list[tuple[str, str]]]: + """Capture CyteType log records as (level, message) pairs.""" + messages: list[tuple[str, str]] = [] + + def _sink(message: "Message") -> None: + record = message.record + messages.append((record["level"].name, record["message"])) + + handler_id = logger.add(_sink, level="INFO") + yield messages + logger.remove(handler_id) diff --git a/tests/test_artifacts.py b/tests/test_artifacts.py index 6bde248..cc9120d 100644 --- a/tests/test_artifacts.py +++ b/tests/test_artifacts.py @@ -1,11 +1,21 @@ +import tempfile +from collections.abc import Iterable +from typing import Any + +import duckdb import pytest import h5py import anndata import numpy as np +import pandas as pd import scipy.sparse as sp from pathlib import Path -from cytetype.core.artifacts import save_features_matrix, _is_integer_valued +from cytetype.core.artifacts import ( + save_features_matrix, + save_obs_duckdb, + _is_integer_valued, +) def test_save_features_matrix_writes_var_metadata( @@ -187,9 +197,17 @@ def test_save_features_matrix_backed_csr(tmp_path: Path) -> None: def test_save_features_matrix_backed_csr_multiple_groups( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - import cytetype.config + # artifacts.py binds WRITE_MEM_BUDGET at import, so patch it there. + monkeypatch.setattr("cytetype.core.artifacts.WRITE_MEM_BUDGET", 256) + progress_labels: list[str] = [] + + def _recording_tqdm(iterable: Iterable[int], desc: str = "", **kwargs: Any) -> Any: + progress_labels.append(desc) + return iterable - monkeypatch.setattr(cytetype.config, "WRITE_MEM_BUDGET", 256) + monkeypatch.setattr( + "cytetype.core.artifacts._try_import_tqdm", lambda: _recording_tqdm + ) n_obs, n_vars = 50, 30 rng = np.random.default_rng(7) @@ -207,6 +225,9 @@ def test_save_features_matrix_backed_csr_multiple_groups( save_features_matrix(out_file=str(out_path), mat=backed.X) backed.file.close() + # The column-group scatter ran in several groups, not one. + assert "Writing normalized counts (group 2)" in progress_labels + with h5py.File(out_path, "r") as f: grp = f["vars"] h5_indptr = grp["indptr"][:] @@ -231,3 +252,169 @@ def test_is_integer_valued_with_float_integers() -> None: def test_is_integer_valued_with_floats() -> None: mat = sp.csr_matrix(np.array([[1.5, 0.0, 3.2], [0.0, 2.7, 0.0]], dtype=np.float32)) assert _is_integer_valued(mat) is False + + +def test_save_features_matrix_hints_backed_mode_only_for_large_in_memory_data( + tmp_path: Path, + mock_adata: anndata.AnnData, + monkeypatch: pytest.MonkeyPatch, + log_messages: list[tuple[str, str]], +) -> None: + def _backed_mode_hints() -> list[str]: + return [message for _, message in log_messages if "backed mode" in message] + + save_features_matrix( + out_file=str(tmp_path / "small.h5"), mat=mock_adata.X, col_batch=10 + ) + assert _backed_mode_hints() == [] + + monkeypatch.setattr( + "cytetype.core.artifacts.BACKED_MODE_HINT_MIN_CELLS", mock_adata.n_obs + ) + save_features_matrix( + out_file=str(tmp_path / "large.h5"), mat=mock_adata.X, col_batch=10 + ) + assert len(_backed_mode_hints()) == 1 + + +def test_save_features_matrix_warns_with_cause_when_raw_group_fails( + tmp_path: Path, + mock_adata: anndata.AnnData, + monkeypatch: pytest.MonkeyPatch, + log_messages: list[tuple[str, str]], +) -> None: + def _fail(*args: Any, **kwargs: Any) -> None: + raise RuntimeError("raw counts exploded") + + monkeypatch.setattr("cytetype.core.artifacts._write_raw_group", _fail) + out_path = tmp_path / "vars.h5" + + save_features_matrix( + out_file=str(out_path), + mat=mock_adata.X, + raw_mat=mock_adata.X, + col_batch=10, + ) + + assert ( + "WARNING", + f"Failed to write raw counts group to {out_path}, skipping: raw counts exploded", + ) in log_messages + with h5py.File(out_path, "r") as f: + assert "raw" not in f + + +def _obs_frame() -> pd.DataFrame: + return pd.DataFrame( + {"cluster": pd.Categorical(["a", "b", "a"]), "n_genes": [10, 20, 30]}, + index=["c1", "c2", "c3"], + ) + + +def test_save_obs_duckdb_spills_to_a_private_temporary_directory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + system_tmp = tmp_path / "system-tmp" + system_tmp.mkdir() + monkeypatch.setattr(tempfile, "tempdir", str(system_tmp)) + spill_dirs: list[Path] = [] + connect = duckdb.connect + + def _connect(database: str, *args: Any, **kwargs: Any) -> Any: + spill_dir = Path(kwargs["config"]["temp_directory"]) + assert spill_dir.is_dir() + spill_dirs.append(spill_dir) + return connect(database, *args, **kwargs) + + monkeypatch.setattr("cytetype.core.artifacts.duckdb.connect", _connect) + out_path = tmp_path / "obs.duckdb" + + save_obs_duckdb(str(out_path), _obs_frame()) + + assert len(spill_dirs) == 1 + assert spill_dirs[0].parent == system_tmp + assert spill_dirs[0].name.startswith("cytetype-duckdb-") + # Removed after writing. + assert list(system_tmp.iterdir()) == [] + with connect(str(out_path), read_only=True) as con: + assert con.execute("SELECT count(*) FROM obs").fetchone() == (3,) + + +def test_save_obs_duckdb_uses_and_keeps_an_explicit_temp_directory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + spill_dir = tmp_path / "spill" + seen: list[str] = [] + connect = duckdb.connect + + def _connect(database: str, *args: Any, **kwargs: Any) -> Any: + seen.append(kwargs["config"]["temp_directory"]) + return connect(database, *args, **kwargs) + + monkeypatch.setattr("cytetype.core.artifacts.duckdb.connect", _connect) + + save_obs_duckdb( + str(tmp_path / "obs.duckdb"), _obs_frame(), temp_directory=str(spill_dir) + ) + + assert seen == [str(spill_dir)] + + +def test_save_obs_duckdb_falls_back_when_temp_directory_cannot_be_created( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + log_messages: list[tuple[str, str]], +) -> None: + def _no_temp_directory(*args: Any, **kwargs: Any) -> Any: + raise OSError(28, "No space left on device") + + configs: list[dict[str, Any]] = [] + connect = duckdb.connect + + def _connect(database: str, *args: Any, **kwargs: Any) -> Any: + configs.append(dict(kwargs["config"])) + return connect(database, *args, **kwargs) + + monkeypatch.setattr(tempfile, "TemporaryDirectory", _no_temp_directory) + monkeypatch.setattr("cytetype.core.artifacts.duckdb.connect", _connect) + obs_df = _obs_frame() + out_path = tmp_path / "obs.duckdb" + + save_obs_duckdb( + str(out_path), + obs_df, + obsm_coordinates=np.arange(6, dtype=float).reshape(3, 2), + coordinates_key="X_umap", + ) + + # DuckDB's default spill location is used, and obs_df is left as it was. + assert "temp_directory" not in configs[0] + assert list(obs_df.columns) == ["cluster", "n_genes"] + assert any( + level == "WARNING" and "No space left on device" in message + for level, message in log_messages + ) + with connect(str(out_path), read_only=True) as con: + assert con.execute( + 'SELECT "__vis_coordinates_X_umap_2" FROM obs' + ).fetchall() == [(1.0,), (3.0,), (5.0,)] + + +def test_save_obs_duckdb_removes_added_columns_when_writing_fails( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + def _connect(*args: Any, **kwargs: Any) -> Any: + raise duckdb.IOException("disk full") + + monkeypatch.setattr("cytetype.core.artifacts.duckdb.connect", _connect) + obs_df = _obs_frame() + + with pytest.raises(duckdb.IOException): + save_obs_duckdb( + str(tmp_path / "obs.duckdb"), + obs_df, + obsm_coordinates=np.zeros((3, 2)), + coordinates_key="X_umap", + ) + + assert list(obs_df.columns) == ["cluster", "n_genes"] diff --git a/tests/test_cytetype_integration.py b/tests/test_cytetype_integration.py index 34a776c..11beaa7 100644 --- a/tests/test_cytetype_integration.py +++ b/tests/test_cytetype_integration.py @@ -1,18 +1,45 @@ """Integration tests for CyteType class.""" +import copy +import inspect +import json +import tempfile from pathlib import Path -from typing import Any +from typing import Any, Literal from unittest.mock import MagicMock, patch import anndata +import duckdb +import numpy as np import pytest import scanpy as sc from pydantic import ValidationError +import cytetype.main from cytetype import CyteType from cytetype.api import UploadResponse -from cytetype.api.exceptions import AuthenticationError, RateLimitError -from cytetype.config import StoredCredentials +from cytetype.api.exceptions import ( + AuthenticationError, + AuthorizationError, + RateLimitError, +) +from cytetype.config import StoredCredentials, load_credentials, save_credentials +from cytetype.core.artifacts import save_obs_duckdb + +# Results stored without raw_annotations get the SDK-only keys as None. +NO_REVIEW_FIELDS = dict.fromkeys( + ("reviewerConfidence", "isHeterogeneous", "heterogeneityType") +) + + +def _saved_credentials(api_token: str) -> StoredCredentials: + return StoredCredentials( + apiUrl="https://cytetype.nygen.io", + apiToken=api_token, + tokenId=f"{api_token}-id", + userId="user-id", + email="researcher@university.edu", + ) def test_fixture_works(mock_adata: anndata.AnnData) -> None: @@ -442,7 +469,10 @@ def test_cytetype_get_results_local( results = ct.get_results() assert results is not None - assert results["annotations"] == mock_api_response["annotations"] + assert results["annotations"] == [ + {**annotation, **NO_REVIEW_FIELDS} + for annotation in mock_api_response["annotations"] + ] assert len(results["annotations"]) == 3 @@ -489,8 +519,9 @@ def test_cytetype_get_results_remote( fetch_args = mock_fetch.call_args.args assert fetch_args[2] == "https://api.test" assert fetch_args[3] == "target_test_token" - assert ct.auth_token == "target_test_token" - assert ct._auth_token_api_url == "https://api.test" + # The saved key is not kept; the token given to CyteType stays bound to its URL. + assert ct.auth_token == "trusted_test_token" + assert ct._auth_token_api_url == "https://trusted.test" @patch("cytetype.main.fetch_remote_results") @@ -676,6 +707,9 @@ def test_cytetype_run_rejects_unsafe_api_url_before_network( monkeypatch.setattr("cytetype.main.submit_annotation_job", submit) monkeypatch.setattr("cytetype.main.wait_for_completion", wait) ct = CyteType(mock_adata, group_key="leiden") + # __init__ only checks for saved credentials to warn early. + credential_loader.assert_called_once_with("https://cytetype.nygen.io") + credential_loader.reset_mock() with pytest.raises(ValueError, match="server origin"): ct.run( @@ -756,7 +790,8 @@ def test_cytetype_run_uses_stored_credentials( ct.run(study_context="Test") - assert ct.auth_token == "stored_test_token" + # Saved credentials are read for each call and not kept on the instance. + assert ct.auth_token is None assert mock_submit.call_args.args[1] == "stored_test_token" assert mock_wait.call_args.args[1] == "stored_test_token" @@ -908,6 +943,7 @@ def test_cytetype_get_results_backwards_compat_dict_format( ) -> None: """Test get_results() handles old dict format (backwards compat).""" ct = CyteType(mock_adata, group_key="leiden") + before = copy.deepcopy(mock_api_response) # Store results in old format (dict, not JSON string) ct.adata.uns["cytetype_results"] = { @@ -919,7 +955,13 @@ def test_cytetype_get_results_backwards_compat_dict_format( results = ct.get_results() assert results is not None - assert results == mock_api_response + assert results == { + **before, + "annotations": [ + {**annotation, **NO_REVIEW_FIELDS} for annotation in before["annotations"] + ], + } + assert ct.adata.uns["cytetype_results"]["result"] == before def test_cytetype_get_results_malformed_json(mock_adata: anndata.AnnData) -> None: @@ -958,3 +1000,684 @@ def test_cytetype_get_results_job_status_failed( results = ct.get_results() assert results is None + + +@pytest.mark.parametrize("require_artifacts", [True, False]) +def test_cytetype_run_raises_upload_authentication_errors_with_setup_hint( + mock_adata: anndata.AnnData, + monkeypatch: pytest.MonkeyPatch, + log_messages: list[tuple[str, str]], + require_artifacts: bool, +) -> None: + upload_error = AuthenticationError( + "API token is invalid or not found. Re-authenticate with valid token.", + error_code="INVALID_TOKEN", + ) + submit = MagicMock() + monkeypatch.setattr( + "cytetype.main.upload_vars_h5_file", MagicMock(side_effect=upload_error) + ) + monkeypatch.setattr("cytetype.main.submit_annotation_job", submit) + ct = CyteType(mock_adata, group_key="leiden") + + with pytest.raises( + AuthenticationError, match="cytetype setup --force" + ) as error_info: + ct.run(study_context="Test", require_artifacts=require_artifacts) + + assert "cytetype login" in error_info.value.message + assert error_info.value.error_code == "INVALID_TOKEN" + assert "API token is invalid or not found" in error_info.value.message + assert error_info.value.__cause__ is upload_error + submit.assert_not_called() + # Not reported as an artifact bug. + assert not any("github" in message.lower() for _, message in log_messages) + + +def test_cytetype_run_raises_upload_authorization_errors_directly( + mock_adata: anndata.AnnData, + monkeypatch: pytest.MonkeyPatch, + log_messages: list[tuple[str, str]], +) -> None: + upload_error = AuthorizationError("License expired", error_code="LICENSE_EXPIRED") + submit = MagicMock() + monkeypatch.setattr( + "cytetype.main.upload_obs_duckdb_file", MagicMock(side_effect=upload_error) + ) + monkeypatch.setattr("cytetype.main.submit_annotation_job", submit) + ct = CyteType(mock_adata, group_key="leiden") + + with pytest.raises(AuthorizationError, match="License expired") as error_info: + ct.run(study_context="Test") + + assert error_info.value.error_code == "LICENSE_EXPIRED" + assert "https://cytetype.nygen.io/dashboard" in error_info.value.message + assert error_info.value.__cause__ is upload_error + submit.assert_not_called() + assert not any("github" in message.lower() for _, message in log_messages) + + +@patch("cytetype.main.wait_for_completion") +@patch("cytetype.main.submit_annotation_job") +def test_cytetype_run_sends_visibility( + mock_submit: MagicMock, + mock_wait: MagicMock, + mock_adata: anndata.AnnData, + mock_api_response: dict[str, Any], +) -> None: + mock_submit.return_value = "job_public" + mock_wait.return_value = mock_api_response + ct = CyteType(mock_adata, group_key="leiden") + + ct.run(study_context="Test", visibility="public") + + payload = mock_submit.call_args.args[2] + assert payload["visibility"] == "public" + assert "visibility" not in payload["input_data"] + assert ct.adata.uns["cytetype_jobDetails"]["visibility"] == "public" + + +@patch("cytetype.main.wait_for_completion") +@patch("cytetype.main.submit_annotation_job") +def test_cytetype_run_omits_visibility_by_default( + mock_submit: MagicMock, + mock_wait: MagicMock, + mock_adata: anndata.AnnData, + mock_api_response: dict[str, Any], + tmp_path: Path, +) -> None: + mock_submit.return_value = "job_default_visibility" + mock_wait.return_value = mock_api_response + ct = CyteType(mock_adata, group_key="leiden") + + ct.run(study_context="Test") + + payload = mock_submit.call_args.args[2] + assert "visibility" not in payload + assert "visibility" not in ct.adata.uns["cytetype_jobDetails"] + ct.adata.write_h5ad(tmp_path / "annotated.h5ad") + + +def test_cytetype_run_rejects_invalid_visibility_before_upload( + mock_adata: anndata.AnnData, + monkeypatch: pytest.MonkeyPatch, +) -> None: + upload_obs = MagicMock() + upload_vars = MagicMock() + submit = MagicMock() + monkeypatch.setattr("cytetype.main.upload_obs_duckdb_file", upload_obs) + monkeypatch.setattr("cytetype.main.upload_vars_h5_file", upload_vars) + monkeypatch.setattr("cytetype.main.submit_annotation_job", submit) + ct = CyteType(mock_adata, group_key="leiden") + + with pytest.raises(ValueError, match="visibility"): + ct.run(study_context="Test", visibility="everyone") # type: ignore[arg-type] + + upload_obs.assert_not_called() + upload_vars.assert_not_called() + submit.assert_not_called() + assert "cytetype_jobDetails" not in ct.adata.uns + + +# Keys in the server's formats. Personal keys have no organization license. +PERSONAL_KEY = "cyt_p_" + "P" * 43 +ORGANIZATION_KEY = "cyt_" + "O" * 43 +PERSONAL_KEY_WARNING = ( + "visibility='org' needs an organization API key, and this key looks like a " + "personal key (cyt_p_...). If it is, the server rejects the job after the upload. " + "Use an organization key, or choose 'private' or 'public'." +) + + +def _visibility_warnings(log_messages: list[tuple[str, str]]) -> list[str]: + # The test data also logs warnings about raw counts and unannotated clusters. + return [ + message + for level, message in log_messages + if level == "WARNING" and "visibility" in message + ] + + +@pytest.mark.parametrize("key_source", ["saved", "auth_token"]) +@patch("cytetype.main.wait_for_completion") +@patch("cytetype.main.submit_annotation_job") +def test_cytetype_run_warns_before_uploading_org_reports_with_a_personal_looking_key( + mock_submit: MagicMock, + mock_wait: MagicMock, + mock_adata: anndata.AnnData, + mock_api_response: dict[str, Any], + monkeypatch: pytest.MonkeyPatch, + log_messages: list[tuple[str, str]], + key_source: str, +) -> None: + mock_submit.return_value = "job_org" + mock_wait.return_value = mock_api_response + fake_upload_vars = cytetype.main.upload_vars_h5_file + warnings_at_upload: list[list[str]] = [] + + def _upload_vars(*args: Any, **kwargs: Any) -> UploadResponse: + warnings_at_upload.append(_visibility_warnings(log_messages)) + return fake_upload_vars(*args, **kwargs) + + upload_vars = MagicMock(side_effect=_upload_vars) + monkeypatch.setattr("cytetype.main.upload_vars_h5_file", upload_vars) + if key_source == "saved": + monkeypatch.setattr( + "cytetype.main.load_credentials", + lambda api_url: _saved_credentials(PERSONAL_KEY), + ) + ct = CyteType(mock_adata, group_key="leiden") + + ct.run( + study_context="Test", + auth_token=PERSONAL_KEY if key_source == "auth_token" else None, + save_query=False, + visibility="org", + ) + + # Logged once, before the upload and without the key. The prefix is not proof, so + # the run goes on and the server decides. + assert _visibility_warnings(log_messages) == [PERSONAL_KEY_WARNING] + assert warnings_at_upload == [[PERSONAL_KEY_WARNING]] + assert upload_vars.call_args.args[1] == PERSONAL_KEY + assert mock_submit.call_args.args[1] == PERSONAL_KEY + payload = mock_submit.call_args.args[2] + assert payload["visibility"] == "org" + assert payload["uploaded_files"] == { + "obs_duckdb": "obs_upload_123", + "vars_h5": "vars_upload_456", + } + assert ct.adata.uns["cytetype_jobDetails"]["visibility"] == "org" + + +@pytest.mark.parametrize("key_source", ["saved", "auth_token"]) +@patch("cytetype.main.wait_for_completion") +@patch("cytetype.main.submit_annotation_job") +def test_cytetype_run_does_not_warn_for_org_reports_with_an_organization_key( + mock_submit: MagicMock, + mock_wait: MagicMock, + mock_adata: anndata.AnnData, + mock_api_response: dict[str, Any], + monkeypatch: pytest.MonkeyPatch, + log_messages: list[tuple[str, str]], + key_source: str, +) -> None: + mock_submit.return_value = "job_org" + mock_wait.return_value = mock_api_response + if key_source == "saved": + monkeypatch.setattr( + "cytetype.main.load_credentials", + lambda api_url: _saved_credentials(ORGANIZATION_KEY), + ) + ct = CyteType(mock_adata, group_key="leiden") + + ct.run( + study_context="Test", + auth_token=ORGANIZATION_KEY if key_source == "auth_token" else None, + save_query=False, + visibility="org", + ) + + assert _visibility_warnings(log_messages) == [] + assert mock_submit.call_args.args[1] == ORGANIZATION_KEY + assert mock_submit.call_args.args[2]["visibility"] == "org" + + +@pytest.mark.parametrize("visibility", ["private", "public", None]) +@patch("cytetype.main.wait_for_completion") +@patch("cytetype.main.submit_annotation_job") +def test_cytetype_run_does_not_warn_about_personal_keys_for_other_visibilities( + mock_submit: MagicMock, + mock_wait: MagicMock, + mock_adata: anndata.AnnData, + mock_api_response: dict[str, Any], + log_messages: list[tuple[str, str]], + visibility: Literal["private", "public"] | None, +) -> None: + mock_submit.return_value = "job_personal" + mock_wait.return_value = mock_api_response + ct = CyteType(mock_adata, group_key="leiden") + + ct.run( + study_context="Test", + auth_token=PERSONAL_KEY, + save_query=False, + visibility=visibility, + ) + + assert _visibility_warnings(log_messages) == [] + assert mock_submit.call_args.args[1] == PERSONAL_KEY + assert mock_submit.call_args.args[2].get("visibility") == visibility + + +def test_visibility_is_the_last_run_parameter() -> None: + parameters = inspect.signature(CyteType.run).parameters + + assert list(parameters)[-1] == "visibility" + assert parameters["visibility"].default is None + + +def test_cytetype_init_warns_when_saved_credentials_file_is_invalid( + mock_adata: anndata.AnnData, + monkeypatch: pytest.MonkeyPatch, + log_messages: list[tuple[str, str]], +) -> None: + load_error = ValueError("Invalid CyteType credentials file: /home/u/c.json") + monkeypatch.setattr( + "cytetype.main.load_credentials", MagicMock(side_effect=load_error) + ) + + ct = CyteType(mock_adata, group_key="leiden") + + warnings = [message for level, message in log_messages if level == "WARNING"] + assert any( + str(load_error) in m + and "Unless you pass auth_token to run()" in m + and "`cytetype logout`, then `cytetype setup`" in m + for m in warnings + ) + assert ct.auth_token is None + + +@pytest.mark.parametrize( + "load_error", + [ + PermissionError("Permission denied"), + RuntimeError("Could not determine home directory."), + ], +) +def test_cytetype_init_warns_when_saved_credentials_cannot_be_read( + mock_adata: anndata.AnnData, + monkeypatch: pytest.MonkeyPatch, + log_messages: list[tuple[str, str]], + load_error: Exception, +) -> None: + monkeypatch.setattr( + "cytetype.main.load_credentials", MagicMock(side_effect=load_error) + ) + + ct = CyteType(mock_adata, group_key="leiden") + + warnings = [message for level, message in log_messages if level == "WARNING"] + assert ( + f"Saved CyteType credentials could not be read ({load_error}). Pass " + "`auth_token`, or make sure XDG_CONFIG_HOME or HOME points to a readable " + "configuration directory." + ) in warnings + assert not any("logout" in message for message in warnings) + assert ct.auth_token is None + + +@pytest.mark.parametrize( + "load_error", + [ + PermissionError("Permission denied"), + RuntimeError("Could not determine home directory."), + ], +) +def test_cytetype_run_reports_unreadable_credentials_as_authentication_error( + mock_adata: anndata.AnnData, + monkeypatch: pytest.MonkeyPatch, + load_error: Exception, +) -> None: + upload_vars = MagicMock() + monkeypatch.setattr( + "cytetype.main.load_credentials", MagicMock(side_effect=load_error) + ) + monkeypatch.setattr("cytetype.main.upload_vars_h5_file", upload_vars) + ct = CyteType(mock_adata, group_key="leiden") + + with pytest.raises(AuthenticationError, match="could not be read") as run_error: + ct.run(study_context="Test") + ct.adata.uns["cytetype_jobDetails"] = {"job_id": "job-1"} + with pytest.raises(AuthenticationError, match="XDG_CONFIG_HOME") as fetch_error: + ct.get_results() + + for error_info in (run_error, fetch_error): + assert error_info.value.error_code == "AUTHENTICATION_REQUIRED" + assert error_info.value.__cause__ is load_error + upload_vars.assert_not_called() + + +def test_cytetype_init_reports_missing_sign_in_before_preparation( + mock_adata: anndata.AnnData, + monkeypatch: pytest.MonkeyPatch, + log_messages: list[tuple[str, str]], +) -> None: + calls: list[str] = [] + resolve_gene_symbols_column = cytetype.main.resolve_gene_symbols_column + + def _load_credentials(api_url: str) -> None: + calls.append(f"credentials {api_url}") + + def _resolve(*args: Any, **kwargs: Any) -> str | None: + calls.append("preparation") + return resolve_gene_symbols_column(*args, **kwargs) + + monkeypatch.setattr("cytetype.main.load_credentials", _load_credentials) + monkeypatch.setattr("cytetype.main.resolve_gene_symbols_column", _resolve) + + ct = CyteType(mock_adata, group_key="leiden") + + assert calls == ["credentials https://cytetype.nygen.io", "preparation"] + # Not a warning: passing auth_token only to run() (as in CI) is fine. + assert ( + "INFO", + "No saved CyteType sign-in found for https://cytetype.nygen.io. Unless you " + "pass auth_token to run(), run `cytetype setup` (or `cytetype login`) before " + "calling run().", + ) in log_messages + assert not any( + level == "WARNING" and "cytetype setup" in message + for level, message in log_messages + ) + assert ct.auth_token is None + assert ct._auth_token_api_url is None + + +def test_cytetype_init_with_saved_credentials_does_not_warn_or_keep_token( + mock_adata: anndata.AnnData, + log_messages: list[tuple[str, str]], +) -> None: + ct = CyteType(mock_adata, group_key="leiden") + + assert not any("cytetype setup" in message for _, message in log_messages) + assert ct.auth_token is None + assert ct._auth_token_api_url is None + + +def test_cytetype_init_with_auth_token_skips_credentials_check( + mock_adata: anndata.AnnData, + monkeypatch: pytest.MonkeyPatch, +) -> None: + credential_loader = MagicMock() + monkeypatch.setattr("cytetype.main.load_credentials", credential_loader) + + CyteType(mock_adata, group_key="leiden", auth_token="explicit_token") + + credential_loader.assert_not_called() + + +def test_cytetype_get_results_renormalizes_stored_results_without_obs_backfill( + mock_adata: anndata.AnnData, +) -> None: + ct = CyteType(mock_adata, group_key="leiden") + stored = { + "annotations": [ + { + "clusterId": "1", + "annotation": "T Cell", + "ontologyTerm": "T cell", + "ontologyTermID": "CL:0000084", + "granularAnnotation": "", + "cellState": "", + "justification": "", + "supportingMarkers": [], + "conflictingMarkers": [], + "missingExpression": "", + "unexpectedExpression": "", + } + ], + "summary": {}, + "clusterCategories": [], + "studyContext": "", + "raw_annotations": { + "1": { + "latest": { + "annotation": { + "clusterId": "1", + "annotation": "T Cell", + "fullOutput": { + "cellType": { + "label": "T Cell", + "keySupportingGenes": ["CD3D", "CD3E"], + "conclusion": "CD3 genes are expressed.", + } + }, + }, + "review": {"confidence": "High", "isHeterogeneous": False}, + }, + "history": [], + } + }, + } + ct.adata.uns["cytetype_results"] = { + "job_id": "stored_job", + "result": json.dumps(stored), + } + obs_columns = list(ct.adata.obs.columns) + + results = ct.get_results() + + assert results is not None + annotation = results["annotations"][0] + assert annotation["supportingMarkers"] == ["CD3D", "CD3E"] + assert annotation["justification"] == "CD3 genes are expressed." + assert annotation["reviewerConfidence"] == "High" + assert annotation["isHeterogeneous"] is False + assert annotation["heterogeneityType"] is None + assert json.loads(ct.adata.uns["cytetype_results"]["result"]) == stored + assert list(ct.adata.obs.columns) == obs_columns + + +@patch("cytetype.main.wait_for_completion") +def test_cytetype_run_reads_replaced_saved_key_after_rejection( + mock_wait: MagicMock, + mock_adata: anndata.AnnData, + mock_api_response: dict[str, Any], + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + monkeypatch.setattr("cytetype.main.load_credentials", load_credentials) + mock_wait.return_value = mock_api_response + submitted_tokens: list[str] = [] + + def _upload_vars(api_url: str, token: str, *args: Any, **kwargs: Any) -> Any: + if token == "revoked_key": + raise AuthenticationError( + "API token is invalid or not found.", error_code="INVALID_TOKEN" + ) + return UploadResponse( + upload_id="vars_upload_456", + file_kind="vars_h5", + file_name="vars.h5", + size_bytes=100, + expires_at="2099-01-01T00:00:00Z", + ) + + def _submit(api_url: str, token: str, payload: dict[str, Any]) -> str: + submitted_tokens.append(token) + return "job_new_key" + + monkeypatch.setattr("cytetype.main.upload_vars_h5_file", _upload_vars) + monkeypatch.setattr("cytetype.main.submit_annotation_job", _submit) + save_credentials(_saved_credentials("revoked_key")) + ct = CyteType(mock_adata, group_key="leiden") + + with pytest.raises(AuthenticationError, match="cytetype setup --force"): + ct.run(study_context="Test") + assert submitted_tokens == [] + + # What `cytetype setup --force` does: save a new key for the same server. + save_credentials(_saved_credentials("new_key")) + ct.run(study_context="Test") + + assert submitted_tokens == ["new_key"] + assert mock_wait.call_args.args[1] == "new_key" + assert ct.auth_token is None + + +def test_cytetype_get_results_reads_saved_credentials_each_time( + mock_adata: anndata.AnnData, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + monkeypatch.setattr("cytetype.main.load_credentials", load_credentials) + fetch = MagicMock(return_value=None) + monkeypatch.setattr("cytetype.main.fetch_remote_results", fetch) + save_credentials(_saved_credentials("first_key")) + ct = CyteType(mock_adata, group_key="leiden") + ct.adata.uns["cytetype_jobDetails"] = { + "job_id": "job-1", + "api_url": "https://cytetype.nygen.io", + } + + ct.get_results() + save_credentials(_saved_credentials("second_key")) + ct.get_results() + + assert [call.args[3] for call in fetch.call_args_list] == [ + "first_key", + "second_key", + ] + + +def test_cytetype_obs_artifact_builds_when_temp_directory_cannot_be_created( + mock_adata: anndata.AnnData, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + def _no_temp_directory(*args: Any, **kwargs: Any) -> Any: + raise OSError(28, "No space left on device") + + monkeypatch.setattr("cytetype.main.save_obs_duckdb_file", save_obs_duckdb) + monkeypatch.setattr(tempfile, "TemporaryDirectory", _no_temp_directory) + obs_path = tmp_path / "obs.duckdb" + obs_columns = list(mock_adata.obs.columns) + + ct = CyteType(mock_adata, group_key="leiden", obs_duckdb_path=str(obs_path)) + + assert ct._artifact_build_errors == [] + assert ct._obs_duckdb_path == str(obs_path) + assert list(mock_adata.obs.columns) == obs_columns + with duckdb.connect(str(obs_path), read_only=True) as con: + columns = [row[0] for row in con.execute("DESCRIBE obs").fetchall()] + assert "__vis_coordinates_X_umap_1" in columns + + +def test_cytetype_failed_obs_artifact_leaves_no_columns_or_files( + mock_adata: anndata.AnnData, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + def _connect(database: str, *args: Any, **kwargs: Any) -> Any: + Path(database).write_bytes(b"partial") + Path(f"{database}.wal").write_bytes(b"partial") + raise duckdb.IOException("disk full") + + monkeypatch.setattr("cytetype.main.save_obs_duckdb_file", save_obs_duckdb) + monkeypatch.setattr("cytetype.core.artifacts.duckdb.connect", _connect) + obs_path = tmp_path / "obs.duckdb" + obs_columns = list(mock_adata.obs.columns) + + ct = CyteType(mock_adata, group_key="leiden", obs_duckdb_path=str(obs_path)) + + assert [name for name, _ in ct._artifact_build_errors] == ["obs_duckdb"] + assert ct._obs_duckdb_path is None + assert list(mock_adata.obs.columns) == obs_columns + assert list(tmp_path.iterdir()) == [] + + +def test_cytetype_failed_vars_artifact_is_deleted( + mock_adata: anndata.AnnData, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + def _write_partial_vars(*args: Any, **kwargs: Any) -> None: + Path(kwargs["out_file"]).write_bytes(b"partial") + raise RuntimeError("disk full") + + monkeypatch.setattr("cytetype.main.save_features_matrix", _write_partial_vars) + vars_path = tmp_path / "vars.h5" + + ct = CyteType(mock_adata, group_key="leiden", vars_h5_path=str(vars_path)) + + assert [name for name, _ in ct._artifact_build_errors] == ["vars_h5"] + assert ct._vars_h5_path is None + assert not vars_path.exists() + + +@patch("cytetype.main.wait_for_completion") +@patch("cytetype.main.submit_annotation_job") +def test_cytetype_run_after_cleanup_requires_a_new_object( + mock_submit: MagicMock, + mock_wait: MagicMock, + mock_adata: anndata.AnnData, + mock_api_response: dict[str, Any], + monkeypatch: pytest.MonkeyPatch, +) -> None: + mock_submit.return_value = "job_first" + mock_wait.return_value = mock_api_response + ct = CyteType(mock_adata, group_key="leiden") + ct.run(study_context="First run") + ct.cleanup() + credential_loader = MagicMock() + upload_vars = MagicMock() + upload_obs = MagicMock() + monkeypatch.setattr("cytetype.main.load_credentials", credential_loader) + monkeypatch.setattr("cytetype.main.upload_vars_h5_file", upload_vars) + monkeypatch.setattr("cytetype.main.upload_obs_duckdb_file", upload_obs) + mock_submit.reset_mock() + + with pytest.raises(ValueError, match="deleted by cleanup"): + ct.run(study_context="Second run", override_existing_results=True) + + credential_loader.assert_not_called() + upload_vars.assert_not_called() + upload_obs.assert_not_called() + mock_submit.assert_not_called() + + +@patch("cytetype.main.wait_for_completion") +@patch("cytetype.main.submit_annotation_job") +def test_cytetype_run_after_cleanup_can_continue_without_artifacts( + mock_submit: MagicMock, + mock_wait: MagicMock, + mock_adata: anndata.AnnData, + mock_api_response: dict[str, Any], + log_messages: list[tuple[str, str]], +) -> None: + mock_submit.return_value = "job_first" + mock_wait.return_value = mock_api_response + ct = CyteType(mock_adata, group_key="leiden") + ct.run(study_context="First run") + ct.cleanup() + mock_submit.return_value = "job_second" + + ct.run( + study_context="Second run", + override_existing_results=True, + require_artifacts=False, + ) + + assert "uploaded_files" not in mock_submit.call_args.args[2] + assert ( + "WARNING", + "The artifact files were deleted by cleanup(). Continuing without them.", + ) in log_messages + assert ct.adata.uns["cytetype_jobDetails"]["job_id"] == "job_second" + + +@patch("cytetype.main.wait_for_completion") +@patch("cytetype.main.submit_annotation_job") +def test_cytetype_run_returns_the_relabeled_object_with_label_na( + mock_submit: MagicMock, + mock_wait: MagicMock, + mock_adata: anndata.AnnData, + mock_api_response: dict[str, Any], +) -> None: + mock_submit.return_value = "job_label_na" + mock_wait.return_value = mock_api_response + mock_adata.obs.loc[mock_adata.obs.index[:5], "leiden"] = np.nan + + ct = CyteType(mock_adata, group_key="leiden", label_na=True) + result = ct.run(study_context="Test") + + # Results go to the returned object, a new one when NaN labels were relabeled. + assert result is ct.adata + assert result is not mock_adata + assert "cytetype_annotation_leiden" in result.obs.columns + assert "cytetype_annotation_leiden" not in mock_adata.obs.columns + assert int(mock_adata.obs["leiden"].isna().sum()) == 5 diff --git a/tests/test_docs_execution.py b/tests/test_docs_execution.py new file mode 100644 index 0000000..990bc52 --- /dev/null +++ b/tests/test_docs_execution.py @@ -0,0 +1,762 @@ +"""Tests for the local notebook executor in docs/execute_docs.py. + +They start real Jupyter kernels and need the docs dependency group; they skip without it. +""" + +from collections.abc import Callable +from copy import deepcopy +from datetime import datetime +import os +from pathlib import Path +import sys +from typing import Any + +import pytest + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +_KERNELSPEC = {"name": "python3", "display_name": "Python 3", "language": "python"} +# Made up, in the format of a personal CyteType API key. +_FAKE_API_KEY = "cyt_p_" + "Fake0Key" * 5 + + +def _require_docs_group() -> Any: + # The executor validates its results with the build, which needs the docs group. + pytest.importorskip("jupytext") + pytest.importorskip("yaml") + pytest.importorskip("nbclient") + pytest.importorskip("ipykernel") + return pytest.importorskip("nbformat") + + +@pytest.fixture(autouse=True) +def _no_local_secrets(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Keep this machine's saved sign-in and secret-looking variables out of the checks.""" + from docs import execute_docs + + for name in list(os.environ): + if execute_docs.is_secret_name(name): + monkeypatch.delenv(name) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + + +@pytest.fixture(scope="module") +def executed_notebook() -> tuple[str, str]: + nbformat = _require_docs_group() + from docs import execute_docs + + notebook = nbformat.v4.new_notebook( + cells=[ + nbformat.v4.new_markdown_cell("# Example"), + nbformat.v4.new_code_cell('print("RESULT")'), + nbformat.v4.new_code_cell(""), + nbformat.v4.new_code_cell( + "import os, sys\n" + 'print(os.environ.get("TQDM_DISABLE"))\n' + 'print(os.environ.get("MPLBACKEND"))\n' + "print(os.path.realpath(sys.executable))\n" + "print(os.getcwd())" + ), + ], + metadata={ + "kernelspec": { + "name": "python3", + "display_name": "Python 3", + "language": "python", + }, + "executed_at": "old execution", + "executed_code_sha256": "old fingerprint", + }, + ) + source = nbformat.writes(notebook) + environment = dict(os.environ) + result = execute_docs.execute_notebook(source) + assert dict(os.environ) == environment + return source, result + + +@pytest.fixture +def build_docs() -> Any: + _require_docs_group() + from docs import build_docs + + return build_docs + + +@pytest.fixture +def execution_source( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, executed_notebook: tuple[str, str] +) -> Any: + from docs import execute_docs + + source = tmp_path / "source" + source.mkdir() + (source / "getting-started").mkdir() + (source / "example.ipynb").write_text(executed_notebook[0]) + monkeypatch.setattr(execute_docs, "SOURCE_DIR", source) + return execute_docs + + +def _execute_results( + monkeypatch: pytest.MonkeyPatch, + runner: Any, + results: list[str | Exception], + *, + before_result: Callable[[int, list[str]], None] | None = None, +) -> list[str]: + """Replace kernel execution with saved results, recording the submitted sources.""" + submitted: list[str] = [] + + def execute_notebook(source: str) -> str: + index = len(submitted) + submitted.append(source) + if before_result is not None: + before_result(index, submitted) + result = results[index] + if isinstance(result, Exception): + raise result + return result + + monkeypatch.setattr(runner, "execute_notebook", execute_notebook) + return submitted + + +def test_execute_notebook_runs_json_notebook_and_records_success( + executed_notebook: tuple[str, str], +) -> None: + nbformat = pytest.importorskip("nbformat") + + notebook = nbformat.reads(executed_notebook[1], as_version=4) + + assert notebook.cells[1].execution_count == 1 + assert notebook.cells[1].outputs[0].text == "RESULT\n" + assert notebook.cells[2].source == "" + assert datetime.fromisoformat(notebook.metadata.executed_at).utcoffset() is not None + assert "executed_code_sha256" not in notebook.metadata + timing = notebook.cells[1].metadata.execution + assert datetime.fromisoformat( + timing["iopub.status.idle"] + ) >= datetime.fromisoformat(timing["iopub.status.busy"]) + + +def test_kernel_runs_this_interpreter_in_a_temporary_directory( + executed_notebook: tuple[str, str], +) -> None: + nbformat = pytest.importorskip("nbformat") + + notebook = nbformat.reads(executed_notebook[1], as_version=4) + lines = notebook.cells[3].outputs[0].text.splitlines() + + assert lines[0] == "1" + assert lines[1] == "module://matplotlib_inline.backend_inline" + assert lines[2] == os.path.realpath(sys.executable) + working_directory = Path(lines[3]) + assert working_directory.name.startswith("cytetype-docs-") + assert not working_directory.is_relative_to(_REPOSITORY_ROOT) + assert not working_directory.exists() + + +def test_execution_timings_preserve_results_and_are_idempotent( + executed_notebook: tuple[str, str], +) -> None: + nbformat = pytest.importorskip("nbformat") + + from docs.execute_docs import add_execution_timings + + notebook = nbformat.reads(executed_notebook[1], as_version=4) + notebook.cells[1].metadata.execution.update( + { + "iopub.status.busy": "2026-10-08T12:34:56+02:00", + "iopub.status.idle": "2026-10-08T12:34:57.250000+02:00", + } + ) + original = deepcopy(notebook) + + add_execution_timings(notebook) + + assert notebook.metadata == original.metadata + assert notebook.cells[0] == original.cells[0] + assert notebook.cells[2] == original.cells[2] + cell = notebook.cells[1] + assert cell.source == original.cells[1].source + assert cell.execution_count == original.cells[1].execution_count + assert cell.metadata == original.cells[1].metadata + assert cell.outputs[:-1] == original.cells[1].outputs + footer = cell.outputs[-1] + assert footer.output_type == "display_data" + assert footer.metadata.cytearc_execution_timing is True + assert ( + footer.data["text/plain"] + == "Started: 2026-10-08T10:34:56Z | Wall time: 1.250 s" + ) + assert footer.data["text/plain"] in footer.data["text/html"] + nbformat.validate(notebook) + first = deepcopy(notebook) + + add_execution_timings(notebook) + + assert notebook == first + + +@pytest.mark.parametrize("failure", ["missing", "invalid", "naive", "negative"]) +def test_execution_timings_reject_missing_or_invalid_records( + executed_notebook: tuple[str, str], failure: str +) -> None: + nbformat = pytest.importorskip("nbformat") + + from docs.execute_docs import add_execution_timings + + notebook = nbformat.reads(executed_notebook[1], as_version=4) + timing = notebook.cells[1].metadata.execution + if failure == "missing": + timing.pop("iopub.status.idle") + elif failure == "invalid": + timing["iopub.status.busy"] = "invalid" + elif failure == "naive": + timing["iopub.status.busy"] = "2026-10-08T12:00:00" + else: + timing["iopub.status.busy"] = "2026-10-08T12:00:00Z" + timing["iopub.status.idle"] = "2026-10-08T11:00:00Z" + + with pytest.raises(ValueError, match="timing"): + add_execution_timings(notebook) + + assert notebook.cells[1].outputs[0].text == "RESULT\n" + assert len(notebook.cells[1].outputs) == 1 + + +def test_execute_notebook_does_not_return_failed_execution( + executed_notebook: tuple[str, str], +) -> None: + nbformat = pytest.importorskip("nbformat") + from nbclient.exceptions import CellExecutionError + + from docs import execute_docs + + notebook = nbformat.reads(executed_notebook[0], as_version=4) + notebook.cells[1].source = 'raise RuntimeError("execution failed")' + + with pytest.raises(CellExecutionError, match="execution failed"): + execute_docs.execute_notebook(nbformat.writes(notebook)) + + +def test_execute_notebook_rejects_unknown_kernels( + executed_notebook: tuple[str, str], +) -> None: + nbformat = pytest.importorskip("nbformat") + from jupyter_client.kernelspec import NoSuchKernel + + from docs import execute_docs + + notebook = nbformat.reads(executed_notebook[0], as_version=4) + notebook.metadata.kernelspec.name = "ir" + + with pytest.raises(NoSuchKernel): + execute_docs.execute_notebook(nbformat.writes(notebook)) + + +@pytest.mark.parametrize("change", ["empty", "skip_execution"]) +def test_reexecution_clears_old_results_before_running_changed_cells( + execution_source: Any, + executed_notebook: tuple[str, str], + monkeypatch: pytest.MonkeyPatch, + build_docs: Any, + change: str, +) -> None: + nbformat = pytest.importorskip("nbformat") + + code_fingerprint = build_docs.code_fingerprint + runner = execution_source + path = runner.SOURCE_DIR / "example.ipynb" + notebook = nbformat.reads(executed_notebook[1], as_version=4) + notebook.metadata["executed_code_sha256"] = code_fingerprint(notebook) + runner.add_execution_timings(notebook) + cell = notebook.cells[1] + cell.metadata["custom"] = {"preserve": True} + cell.source = "" if change == "empty" else 'print("UPDATED")' + if change == "skip_execution": + cell.metadata["tags"] = ["skip-execution"] + source = nbformat.writes(notebook) + path.write_text(source) + result = runner.execute_notebook(source) + _execute_results(monkeypatch, runner, [result]) + + if change == "empty": + runner.main(["example"]) + saved = nbformat.read(path, as_version=4) + assert saved.cells[1].outputs == [] + assert saved.cells[1].execution_count is None + assert saved.metadata.executed_code_sha256 == code_fingerprint(saved) + else: + with pytest.raises(SystemExit, match="unexecuted cell 2"): + runner.main(["example"]) + assert path.read_text() == source + + returned = nbformat.reads(result, as_version=4) + assert returned.cells[1].outputs == [] + assert returned.cells[1].execution_count is None + assert "execution" not in returned.cells[1].metadata + assert returned.cells[1].metadata.custom == {"preserve": True} + assert returned.cells[0] == notebook.cells[0] + if change == "skip_execution": + assert returned.cells[1].metadata.tags == ["skip-execution"] + + +@pytest.mark.parametrize( + "name", + [ + "example", + "example.ipynb", + "getting-started/example", + "getting-started/example.ipynb", + ], +) +def test_page_path_resolves_notebooks(execution_source: Any, name: str) -> None: + runner = execution_source + section = runner.SOURCE_DIR / "getting-started" / "example.ipynb" + if name.startswith("getting-started/"): + section.write_text((runner.SOURCE_DIR / "example.ipynb").read_text()) + + expected = ( + section + if name.startswith("getting-started/") + else runner.SOURCE_DIR / "example.ipynb" + ) + assert runner.page_path(name) == expected.resolve() + + +def test_page_path_resolves_a_notebook_in_a_section_by_name( + execution_source: Any, +) -> None: + runner = execution_source + path = runner.SOURCE_DIR / "example.ipynb" + destination = runner.SOURCE_DIR / "getting-started" / path.name + path.rename(destination) + + assert runner.page_path("example") == destination.resolve() + assert runner.page_path("example.ipynb") == destination.resolve() + + +def test_page_path_rejects_names_in_several_sections(execution_source: Any) -> None: + runner = execution_source + path = runner.SOURCE_DIR / "example.ipynb" + (runner.SOURCE_DIR / "guides").mkdir() + for section in ("getting-started", "guides"): + (runner.SOURCE_DIR / section / path.name).write_text(path.read_text()) + + # A notebook at the top level takes precedence over notebooks in sections. + assert runner.page_path("example") == path.resolve() + path.unlink() + with pytest.raises(ValueError, match="Ambiguous") as error: + runner.page_path("example") + assert "getting-started/example.ipynb" in str(error.value) + assert "guides/example.ipynb" in str(error.value) + assert ( + runner.page_path("guides/example") + == (runner.SOURCE_DIR / "guides" / "example.ipynb").resolve() + ) + + +@pytest.mark.parametrize("name", ["example.md", "getting-started/example.md"]) +def test_page_path_rejects_markdown_aliases(execution_source: Any, name: str) -> None: + runner = execution_source + (runner.SOURCE_DIR / name).write_text("# Source-only page\n") + + with pytest.raises(ValueError, match="Expected a documentation notebook"): + runner.page_path(name) + + +@pytest.mark.parametrize( + "name", ["../outside.ipynb", "linked.ipynb", "missing", "getting-started"] +) +def test_page_path_rejects_notebooks_outside_source( + execution_source: Any, name: str +) -> None: + runner = execution_source + outside = runner.SOURCE_DIR.parent / "outside.ipynb" + outside.write_text((runner.SOURCE_DIR / "example.ipynb").read_text()) + (runner.SOURCE_DIR / "linked.ipynb").symlink_to(outside) + + with pytest.raises(ValueError, match="Unknown documentation page"): + runner.page_path(name) + + +def test_main_validates_and_replaces_canonical_notebook( + execution_source: Any, + executed_notebook: tuple[str, str], + monkeypatch: pytest.MonkeyPatch, + build_docs: Any, +) -> None: + nbformat = pytest.importorskip("nbformat") + + code_fingerprint = build_docs.code_fingerprint + validate_execution = build_docs.validate_execution + runner = execution_source + path = runner.SOURCE_DIR / "example.ipynb" + submitted = _execute_results(monkeypatch, runner, [executed_notebook[1]]) + + runner.main(["example", "example.ipynb"]) + + assert submitted == [executed_notebook[0]] + notebook = nbformat.read(path, as_version=4) + validate_execution(notebook, Path("example.ipynb")) + assert notebook.metadata.executed_code_sha256 == code_fingerprint(notebook) + assert notebook.cells[1].outputs[0].text == "RESULT\n" + assert notebook.cells[1].outputs[-1].metadata.cytearc_execution_timing is True + assert {entry.name for entry in runner.SOURCE_DIR.iterdir()} == { + "example.ipynb", + "getting-started", + } + + +def test_main_continues_after_a_failed_page_and_preserves_its_source( + execution_source: Any, + executed_notebook: tuple[str, str], + monkeypatch: pytest.MonkeyPatch, + build_docs: Any, +) -> None: + nbformat = pytest.importorskip("nbformat") + + runner = execution_source + failed = runner.SOURCE_DIR / "example.ipynb" + successful = runner.SOURCE_DIR / "getting-started" / "second.ipynb" + successful.write_text(executed_notebook[0]) + submitted = _execute_results( + monkeypatch, + runner, + [RuntimeError("kernel failed"), executed_notebook[1]], + ) + + with pytest.raises(SystemExit, match="example.ipynb: kernel failed"): + runner.main(["example", "second"]) + + assert len(submitted) == 2 + assert failed.read_text() == executed_notebook[0] + assert ( + nbformat.read(successful, as_version=4).cells[1].outputs[0].text == "RESULT\n" + ) + + +def test_main_preserves_edits_made_during_execution( + execution_source: Any, + executed_notebook: tuple[str, str], + monkeypatch: pytest.MonkeyPatch, + build_docs: Any, +) -> None: + nbformat = pytest.importorskip("nbformat") + + runner = execution_source + path = runner.SOURCE_DIR / "example.ipynb" + edited = nbformat.reads(executed_notebook[0], as_version=4) + edited.cells[0].source = "# User edit while execution is running" + text = nbformat.writes(edited) + + def edit_source(index: int, submitted: list[str]) -> None: + path.write_text(text) + + _execute_results( + monkeypatch, runner, [executed_notebook[1]], before_result=edit_source + ) + + with pytest.raises(SystemExit, match="Notebook changed during execution"): + runner.main(["example"]) + + assert path.read_text() == text + assert not list(runner.SOURCE_DIR.glob(".example-*")) + + +@pytest.mark.parametrize("change", ["code", "empty_cell"]) +def test_main_rejects_results_from_different_code( + execution_source: Any, + executed_notebook: tuple[str, str], + monkeypatch: pytest.MonkeyPatch, + build_docs: Any, + change: str, +) -> None: + nbformat = pytest.importorskip("nbformat") + + runner = execution_source + result = nbformat.reads(executed_notebook[1], as_version=4) + if change == "code": + result.cells[1].source = 'print("OTHER")' + else: + result.cells.pop(2) + _execute_results(monkeypatch, runner, [nbformat.writes(result)]) + + with pytest.raises(SystemExit, match="Executed code differs"): + runner.main(["example"]) + + assert (runner.SOURCE_DIR / "example.ipynb").read_text() == executed_notebook[0] + + +@pytest.mark.parametrize("failure", ["timestamp", "unexecuted", "error", "timing"]) +def test_main_rejects_incomplete_results_without_overwriting_source( + execution_source: Any, + executed_notebook: tuple[str, str], + monkeypatch: pytest.MonkeyPatch, + build_docs: Any, + failure: str, +) -> None: + nbformat = pytest.importorskip("nbformat") + + runner = execution_source + notebook = nbformat.reads(executed_notebook[1], as_version=4) + if failure == "timestamp": + notebook.metadata.pop("executed_at") + elif failure == "unexecuted": + notebook.cells[1].execution_count = None + elif failure == "timing": + notebook.cells[1].metadata.pop("execution") + else: + notebook.cells[1].outputs = [ + nbformat.v4.new_output( + "error", ename="RuntimeError", evalue="failed", traceback=["failed"] + ) + ] + _execute_results(monkeypatch, runner, [nbformat.writes(notebook)]) + + with pytest.raises(SystemExit, match="Notebook execution failed"): + runner.main(["example"]) + + assert (runner.SOURCE_DIR / "example.ipynb").read_text() == executed_notebook[0] + + +def test_main_checks_all_sources_before_executing( + execution_source: Any, monkeypatch: pytest.MonkeyPatch, build_docs: Any +) -> None: + nbformat = pytest.importorskip("nbformat") + + runner = execution_source + invalid = runner.SOURCE_DIR / "getting-started" / "invalid.ipynb" + notebook = nbformat.read(runner.SOURCE_DIR / "example.ipynb", as_version=4) + notebook.metadata.pop("kernelspec") + nbformat.write(notebook, invalid) + submitted = _execute_results(monkeypatch, runner, []) + + with pytest.raises(ValueError, match="Not an executable notebook"): + runner.main(["example", "invalid"]) + + assert submitted == [] + + +def test_main_requires_pages(execution_source: Any, build_docs: Any) -> None: + with pytest.raises(ValueError, match="Specify affected pages"): + execution_source.main([]) + + +def _notebook_with_outputs(nbformat: Any, outputs: list[Any]) -> Any: + return nbformat.v4.new_notebook( + cells=[ + nbformat.v4.new_markdown_cell("# Example"), + nbformat.v4.new_code_cell( + 'print("RESULT")', execution_count=1, outputs=outputs + ), + ], + metadata={"kernelspec": _KERNELSPEC}, + ) + + +def test_kernel_environment_drops_secret_looking_variables() -> None: + from docs import execute_docs + + kept = { + "HOME": "/home/researcher", + "XDG_CONFIG_HOME": "/home/researcher/.config", + "PATH": "/usr/local/bin:/usr/bin", + "TMPDIR": "/scratch/tmp", + "CYTETYPE_API_URL": "https://cytetype.example", + } + dropped = { + "OPENAI_API_KEY": "sk-example", + "GITHUB_TOKEN": "ghp-example", + "aws_secret_access_key": "example", + "DB_PASSWORD": "example", + "MYSQL_PASSWD": "example", + "GOOGLE_APPLICATION_CREDENTIALS": "/home/researcher/service-account.json", + "Hf_Token": "example", + } + environment = {**kept, **dropped, "TQDM_DISABLE": "0"} + + result = execute_docs.kernel_environment(environment) + + assert result == {**kept, **execute_docs.KERNEL_ENVIRONMENT} + assert environment == {**kept, **dropped, "TQDM_DISABLE": "0"} + + +def test_kernel_starts_without_secret_looking_variables( + monkeypatch: pytest.MonkeyPatch, +) -> None: + nbformat = _require_docs_group() + from docs import execute_docs + + monkeypatch.setenv("CYTETYPE_DOCS_TEST_TOKEN", "kept-out-of-the-kernel") + monkeypatch.setenv("CYTETYPE_API_URL", "https://cytetype.example") + names = ["CYTETYPE_DOCS_TEST_TOKEN", "CYTETYPE_API_URL", "XDG_CONFIG_HOME", "HOME"] + notebook = nbformat.v4.new_notebook( + cells=[ + nbformat.v4.new_code_cell( + f"import os\nfor name in {names!r}:\n print(os.environ.get(name))" + ) + ], + metadata={"kernelspec": _KERNELSPEC}, + ) + + result = execute_docs.execute_notebook(nbformat.writes(notebook)) + + # The kernel keeps what it needs to find the saved CyteType sign-in. + lines = nbformat.reads(result, as_version=4).cells[0].outputs[0].text.splitlines() + assert lines == [ + "None", + "https://cytetype.example", + os.environ["XDG_CONFIG_HOME"], + str(os.environ.get("HOME")), + ] + + +@pytest.mark.parametrize("place", ["stream", "split_stream", "html", "traceback"]) +def test_outputs_with_a_cytetype_api_key_are_refused(place: str) -> None: + nbformat = pytest.importorskip("nbformat") + from docs import execute_docs + + new_output = nbformat.v4.new_output + if place == "stream": + outputs = [new_output("stream", name="stdout", text=f"{_FAKE_API_KEY}\n")] + elif place == "split_stream": + # Neither part matches on its own. + outputs = [ + new_output("stream", name="stdout", text=f"Key: {_FAKE_API_KEY[:10]}"), + new_output("stream", name="stdout", text=f"{_FAKE_API_KEY[10:]}\n"), + ] + elif place == "html": + outputs = [ + new_output( + "display_data", + data={"text/plain": "Table", "text/html": f"{_FAKE_API_KEY}"}, + ) + ] + else: + outputs = [ + new_output( + "error", + ename="ValueError", + evalue="Invalid API token", + traceback=[f"auth_token = '{_FAKE_API_KEY}'"], + ) + ] + notebook = _notebook_with_outputs(nbformat, outputs) + + with pytest.raises(ValueError) as error: + execute_docs.check_outputs_for_secrets(notebook, environment={}) + + # Exact, so the message names the cell and never shows the key. + assert str(error.value) == ( + "Refusing to save outputs that contain secrets: a CyteType API key in cell 2" + ) + + +def test_outputs_with_the_value_of_a_secret_variable_are_refused() -> None: + nbformat = pytest.importorskip("nbformat") + from docs import execute_docs + + secret = "correct-horse-battery" + notebook = _notebook_with_outputs( + nbformat, + [ + nbformat.v4.new_output( + "display_data", data={"text/plain": f"password: {secret}"} + ), + # Values of other variables, and values too short to check, may appear. + nbformat.v4.new_output( + "stream", name="stdout", text="/home/researcher 1234567\n" + ), + ], + ) + environment = { + "DATABASE_PASSWORD": secret, + "HOME": "/home/researcher", + "SHORT_TOKEN": "1234567", + } + + with pytest.raises(ValueError) as error: + execute_docs.check_outputs_for_secrets(notebook, environment) + + assert str(error.value) == ( + "Refusing to save outputs that contain secrets: " + "the value of DATABASE_PASSWORD in cell 2" + ) + + +def test_outputs_with_the_saved_api_key_are_refused() -> None: + nbformat = pytest.importorskip("nbformat") + from cytetype.config import StoredCredentials, save_credentials + from docs import execute_docs + + # Not in the cyt_ format, so only the saved key itself identifies it. + key = "saved-key-0123456789abcdef" + save_credentials( + StoredCredentials( + apiUrl="https://cytetype.nygen.io", + apiToken=key, + tokenId="token-id", + userId="user-id", + email="researcher@university.edu", + ) + ) + notebook = _notebook_with_outputs( + nbformat, [nbformat.v4.new_output("stream", name="stderr", text=f"{key}\n")] + ) + + with pytest.raises(ValueError) as error: + execute_docs.check_outputs_for_secrets(notebook, environment={}) + + assert str(error.value) == ( + "Refusing to save outputs that contain secrets: " + "the saved CyteType API key in cell 2" + ) + + +def test_outputs_without_secrets_are_accepted( + executed_notebook: tuple[str, str], monkeypatch: pytest.MonkeyPatch +) -> None: + nbformat = pytest.importorskip("nbformat") + from docs import execute_docs + + monkeypatch.setenv("CYTETYPE_DOCS_TEST_TOKEN", "not-in-any-output") + notebook = nbformat.reads(executed_notebook[1], as_version=4) + notebook.cells[1].outputs.append( + nbformat.v4.new_output( + "display_data", + data={ + "text/plain": "cytetype_annotation_leiden\n" + "https://cytetype.nygen.io/report/0123\n" + "Personal API keys start with cyt_p_.", + "text/html": "cytetype_annotation_leiden", + }, + ) + ) + + execute_docs.check_outputs_for_secrets(notebook) + + +def test_main_refuses_to_save_outputs_with_secrets( + execution_source: Any, + executed_notebook: tuple[str, str], + monkeypatch: pytest.MonkeyPatch, + build_docs: Any, + capsys: pytest.CaptureFixture[str], +) -> None: + nbformat = pytest.importorskip("nbformat") + + runner = execution_source + notebook = nbformat.reads(executed_notebook[1], as_version=4) + notebook.cells[1].outputs[0].text = f"RESULT {_FAKE_API_KEY}\n" + _execute_results(monkeypatch, runner, [nbformat.writes(notebook)]) + + with pytest.raises(SystemExit) as error: + runner.main(["example"]) + + assert str(error.value) == ( + "Notebook execution failed:\nexample.ipynb: Refusing to save outputs that " + "contain secrets: a CyteType API key in cell 2" + ) + assert _FAKE_API_KEY not in capsys.readouterr().out + assert (runner.SOURCE_DIR / "example.ipynb").read_text() == executed_notebook[0] diff --git a/tests/test_docs_render.py b/tests/test_docs_render.py new file mode 100644 index 0000000..9e68d9f --- /dev/null +++ b/tests/test_docs_render.py @@ -0,0 +1,1439 @@ +"""Tests for the documentation build in docs/build_docs.py and docs/render_outputs.py. + +The build tests need the docs dependency group and skip without it. +""" + +from copy import deepcopy +from datetime import datetime +from html.parser import HTMLParser +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +from typing import Any +from xml.etree import ElementTree + +import pytest + +_DOCS = Path(__file__).resolve().parents[1] / "docs" + + +@pytest.fixture +def executable_docs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Any: + pytest.importorskip("jupytext") + nbformat = pytest.importorskip("nbformat") + yaml = pytest.importorskip("yaml") + from docs import build_docs + + source = tmp_path / "source" + source.mkdir() + (source / "myst.yml").write_text( + yaml.safe_dump( + { + "version": 1, + "project": { + "github": "https://github.com/example/docs", + "toc": [{"file": "example.ipynb"}], + }, + } + ) + ) + saved = nbformat.v4.new_notebook( + cells=[ + nbformat.v4.new_markdown_cell("# Example"), + nbformat.v4.new_code_cell( + 'print("RESULT")', + execution_count=1, + outputs=[ + nbformat.v4.new_output("stream", name="stdout", text="RESULT\n") + ], + ), + ], + metadata={"kernelspec": {"name": "python3", "display_name": "Python 3"}}, + ) + saved.metadata["executed_at"] = "2026-10-07T22:00:00+00:00" + saved.metadata["executed_code_sha256"] = build_docs.code_fingerprint(saved) + nbformat.write(saved, source / "example.ipynb") + monkeypatch.setattr(build_docs, "SOURCE", source) + monkeypatch.setattr(build_docs, "SITE", tmp_path / "site") + return build_docs + + +def test_html_requires_saved_execution_for_every_executable_page( + executable_docs: Any, +) -> None: + nbformat = pytest.importorskip("nbformat") + + build_docs = executable_docs + notebook = nbformat.read(build_docs.SOURCE / "example.ipynb", as_version=4) + notebook.metadata.pop("executed_at") + notebook.metadata.pop("executed_code_sha256") + notebook.cells[1].execution_count = None + notebook.cells[1].outputs = [] + for page in ("example.ipynb", "second.ipynb"): + nbformat.write(notebook, build_docs.SOURCE / page) + + with pytest.raises(ValueError, match="Saved execution is missing") as error: + build_docs.prepare_site() + + assert "example.ipynb" in str(error.value) + assert "second.ipynb" in str(error.value) + assert "--preview" in str(error.value) + + +@pytest.mark.parametrize("preview", [False, True]) +def test_code_cells_require_kernel_metadata( + executable_docs: Any, preview: bool +) -> None: + nbformat = pytest.importorskip("nbformat") + + build_docs = executable_docs + source = build_docs.SOURCE / "example.ipynb" + notebook = nbformat.read(source, as_version=4) + notebook.metadata.pop("kernelspec") + nbformat.write(notebook, source) + + with pytest.raises(ValueError, match="example.ipynb has no kernelspec metadata"): + build_docs.prepare_site(preview=preview) + + +def test_plain_python_examples_need_no_execution(executable_docs: Any) -> None: + yaml = pytest.importorskip("yaml") + + build_docs = executable_docs + (build_docs.SOURCE / "example.ipynb").unlink() + (build_docs.SOURCE / "example.md").write_text( + '# Example\n\n```python\nprint("RESULT")\n```\n' + ) + config_path = build_docs.SOURCE / "myst.yml" + config = yaml.safe_load(config_path.read_text()) + config["project"]["toc"] = [{"file": "example.md"}] + config_path.write_text(yaml.safe_dump(config)) + + site = build_docs.prepare_site() + + assert (site / "example.md").is_file() + assert not (site / "example.ipynb").exists() + + +def test_source_and_edit_links_target_master(executable_docs: Any) -> None: + nbformat = pytest.importorskip("nbformat") + yaml = pytest.importorskip("yaml") + + build_docs = executable_docs + (build_docs.SOURCE / "guide.md").write_text("# Guide\n") + + site = build_docs.prepare_site() + notebook = nbformat.read(site / "example.ipynb", as_version=4) + guide = yaml.safe_load((site / "guide.md").read_text().split("---", 2)[1]) + + for page, metadata in (("example.ipynb", notebook.metadata), ("guide.md", guide)): + assert metadata["source_url"] == ( + f"https://github.com/example/docs/blob/master/docs/source/{page}" + ) + assert metadata["edit_url"] == ( + f"https://github.com/example/docs/edit/master/docs/source/{page}" + ) + + +def test_saved_notebooks_restore_complete_outputs_in_a_clean_checkout( + executable_docs: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + nbformat = pytest.importorskip("nbformat") + yaml = pytest.importorskip("yaml") + + build_docs = executable_docs + held = build_docs.SOURCE / "held.ipynb" + held.write_text("Excluded outputs") + config_path = build_docs.SOURCE / "myst.yml" + config = yaml.safe_load(config_path.read_text()) + config["project"]["exclude"] = ["held.ipynb"] + config_path.write_text(yaml.safe_dump(config)) + checkout = tmp_path / "checkout" / "docs" + shutil.copytree(build_docs.SOURCE, checkout / "source") + monkeypatch.setattr(build_docs, "SOURCE", checkout / "source") + monkeypatch.setattr(build_docs, "SITE", checkout / "build" / "site") + assert not (checkout / "build").exists() + source = build_docs.SOURCE / "example.ipynb" + current = nbformat.read(source, as_version=4) + current.cells[0].source = "# Updated" + nbformat.write(current, source) + source_text = source.read_text() + + site = build_docs.prepare_site() + notebook = nbformat.read(site / "example.ipynb", as_version=4) + + assert notebook.cells[0].source == "# Updated" + assert notebook.cells[1].outputs[0].text == "RESULT\n" + assert notebook.cells[1].execution_count == 1 + assert notebook.metadata["executed_at"] == "2026-10-07T22:00:00+00:00" + assert ( + notebook.metadata["executed_code_sha256"] + == (current.metadata["executed_code_sha256"]) + ) + assert not (site / "held.ipynb").exists() + assert source.read_text() == source_text + + current.cells[1].source = 'print("CHANGED")' + nbformat.write(current, source) + with pytest.raises(ValueError, match="Code changed"): + build_docs.prepare_site() + + +def test_preview_labels_missing_execution_without_fabricating_outputs( + executable_docs: Any, +) -> None: + nbformat = pytest.importorskip("nbformat") + + build_docs = executable_docs + source = build_docs.SOURCE / "example.ipynb" + current = nbformat.read(source, as_version=4) + current.metadata.pop("executed_at") + current.metadata.pop("executed_code_sha256") + current.cells[1].execution_count = None + current.cells[1].outputs = [] + current.cells[1].metadata["figure_descriptions"] = [ + "An embedding that will be generated when this page is executed." + ] + nbformat.write(current, source) + source_text = source.read_text() + + site = build_docs.prepare_site(preview=True) + notebook = nbformat.read(site / "example.ipynb", as_version=4) + + assert notebook.cells[0].cell_type == "markdown" + assert notebook.cells[0].source.startswith(":::{warning} Source preview\n") + assert "Figures and results are not available" in notebook.cells[0].source + assert "executed_at" not in notebook.metadata + assert "executed_code_sha256" not in notebook.metadata + assert all( + cell.execution_count is None and cell.outputs == [] + for cell in notebook.cells + if cell.cell_type == "code" + ) + assert notebook.cells[2].metadata.figure_descriptions == ( + current.cells[1].metadata.figure_descriptions + ) + assert source.read_text() == source_text + + +@pytest.mark.parametrize("preview", [False, True]) +@pytest.mark.parametrize( + ("failure", "message"), + [ + ("timestamp", "no completed execution timestamp"), + ("fingerprint", "fingerprint"), + ("unexecuted", "unexecuted cell"), + ("error", "has an error in cell"), + ("changed_code", "Code changed"), + ], +) +def test_build_rejects_invalid_saved_execution( + executable_docs: Any, preview: bool, failure: str, message: str +) -> None: + nbformat = pytest.importorskip("nbformat") + + build_docs = executable_docs + saved_path = build_docs.SOURCE / "example.ipynb" + saved = nbformat.read(saved_path, as_version=4) + if failure == "timestamp": + saved.metadata.pop("executed_at") + elif failure == "fingerprint": + saved.metadata.pop("executed_code_sha256") + elif failure == "unexecuted": + saved.cells[1].execution_count = None + elif failure == "error": + saved.cells[1].outputs = [ + nbformat.v4.new_output( + "error", ename="RuntimeError", evalue="failed", traceback=["failed"] + ) + ] + else: + saved.cells[1].source = 'print("OLD RESULT")' + nbformat.write(saved, saved_path) + + with pytest.raises(ValueError, match=message): + build_docs.prepare_site(preview=preview) + + +def test_completed_cells_need_not_produce_outputs(executable_docs: Any) -> None: + nbformat = pytest.importorskip("nbformat") + + build_docs = executable_docs + saved_path = build_docs.SOURCE / "example.ipynb" + saved = nbformat.read(saved_path, as_version=4) + saved.cells[1].source = "value = 1" + saved.cells[1].outputs = [] + saved.metadata["executed_code_sha256"] = build_docs.code_fingerprint(saved) + nbformat.write(saved, saved_path) + + site = build_docs.prepare_site() + notebook = nbformat.read(site / "example.ipynb", as_version=4) + + assert notebook.cells[1].execution_count == 1 + assert notebook.cells[1].outputs == [] + assert "Source preview" not in notebook.cells[0].source + + +@pytest.mark.parametrize( + ("exclude", "suffix"), + [ + ("tutorials/held.ipynb", ".ipynb"), + ("tutorials/**", ".ipynb"), + ("**/held.ipynb", ".ipynb"), + ("tutorials/held.md", ".md"), + ], +) +def test_excluded_pages_need_no_execution_and_leave_no_generated_routes( + executable_docs: Any, exclude: str, suffix: str +) -> None: + nbformat = pytest.importorskip("nbformat") + yaml = pytest.importorskip("yaml") + + build_docs = executable_docs + held = build_docs.SOURCE / "tutorials" / f"held{suffix}" + held.parent.mkdir() + if suffix == ".md": + held.write_text("# Held\n\n```{code-cell} python\nraise RuntimeError\n```\n") + else: + held.write_text("Invalid excluded notebook") + config_path = build_docs.SOURCE / "myst.yml" + config = yaml.safe_load(config_path.read_text()) + config["project"]["exclude"] = [exclude] + config["project"]["toc"].append( + {"title": "Tutorials", "children": [{"file": f"tutorials/held{suffix}"}]} + ) + config_path.write_text(yaml.safe_dump(config)) + old_html = build_docs.SITE / "_build" / "html" + old_html.mkdir(parents=True) + (old_html / "held.html").write_text("Old held page") + (old_html / "held.png").write_bytes(b"old figure") + (build_docs.SITE / "held.ipynb").write_text("Old staged notebook") + + site = build_docs.prepare_site() + notebook = nbformat.read(site / "example.ipynb", as_version=4) + staged_config = yaml.safe_load((site / "myst.yml").read_text()) + + assert notebook.cells[1].outputs[0].text == "RESULT\n" + assert not (site / "tutorials" / "held.md").exists() + assert not (site / "tutorials" / "held.ipynb").exists() + assert not (site / "held.ipynb").exists() + assert not old_html.exists() + assert staged_config["project"]["toc"][1]["children"] == [] + assert held.is_file() + assert yaml.safe_load(config_path.read_text()) == config + + +@pytest.mark.parametrize("change", ["edit", "reorder", "delete"]) +@pytest.mark.parametrize("preview", [False, True]) +def test_saved_outputs_follow_unchanged_code_and_current_prose( + executable_docs: Any, change: str, preview: bool +) -> None: + nbformat = pytest.importorskip("nbformat") + + build_docs = executable_docs + source = build_docs.SOURCE / "example.ipynb" + saved = nbformat.read(source, as_version=4) + saved.cells.append( + nbformat.v4.new_code_cell( + 'print("SECOND")', + execution_count=2, + outputs=[nbformat.v4.new_output("stream", name="stdout", text="SECOND\n")], + ) + ) + saved.metadata["executed_code_sha256"] = build_docs.code_fingerprint(saved) + current = deepcopy(saved) + current.cells[0].source = "# Edited prose" + current.cells.insert(2, nbformat.v4.new_markdown_cell("More explanation.")) + nbformat.write(current, source) + + site = build_docs.prepare_site(preview=preview) + rendered = nbformat.read(site / "example.ipynb", as_version=4) + + assert rendered.cells[0].source == "# Edited prose" + assert rendered.cells[2].source == "More explanation." + assert [cell for cell in rendered.cells if cell.cell_type == "code"] == ( + [cell for cell in saved.cells if cell.cell_type == "code"] + ) + assert rendered.metadata["executed_at"] == saved.metadata["executed_at"] + assert ( + rendered.metadata["executed_code_sha256"] + == (saved.metadata["executed_code_sha256"]) + ) + + changed = deepcopy(current) + if change == "edit": + changed.cells[1].source = 'print("CHANGED")' + elif change == "reorder": + changed.cells[1], changed.cells[3] = changed.cells[3], changed.cells[1] + else: + del changed.cells[3] + nbformat.write(changed, source) + with pytest.raises(ValueError, match='execute-docs PAGES="example"'): + build_docs.prepare_site(preview=preview) + + +def test_rerun_command_names_the_notebook_in_its_section( + executable_docs: Any, +) -> None: + nbformat = pytest.importorskip("nbformat") + yaml = pytest.importorskip("yaml") + + build_docs = executable_docs + section = build_docs.SOURCE / "getting-started" + section.mkdir() + notebook = nbformat.read(build_docs.SOURCE / "example.ipynb", as_version=4) + notebook.cells[1].source = 'print("CHANGED")' + nbformat.write(notebook, section / "quickstart.ipynb") + (build_docs.SOURCE / "example.ipynb").unlink() + config_path = build_docs.SOURCE / "myst.yml" + config = yaml.safe_load(config_path.read_text()) + config["project"]["toc"] = [{"file": "getting-started/quickstart.ipynb"}] + config_path.write_text(yaml.safe_dump(config)) + + with pytest.raises(ValueError) as error: + build_docs.prepare_site() + + assert 'make -C docs execute-docs PAGES="getting-started/quickstart"' in str( + error.value + ) + + +@pytest.mark.parametrize("state", ["count", "output", "timestamp", "fingerprint"]) +def test_preview_rejects_partial_execution(executable_docs: Any, state: str) -> None: + nbformat = pytest.importorskip("nbformat") + + build_docs = executable_docs + source = build_docs.SOURCE / "example.ipynb" + current = nbformat.read(source, as_version=4) + current.metadata.pop("executed_at") + current.metadata.pop("executed_code_sha256") + current.cells[1].execution_count = None + current.cells[1].outputs = [] + if state == "count": + current.cells[1].execution_count = 1 + elif state == "output": + current.cells[1].outputs = [ + nbformat.v4.new_output("stream", name="stdout", text="Partial result\n") + ] + elif state == "timestamp": + current.metadata["executed_at"] = "2026-10-07T22:00:00+00:00" + else: + current.metadata["executed_code_sha256"] = build_docs.code_fingerprint(current) + nbformat.write(current, source) + source_text = source.read_text() + + with pytest.raises(ValueError): + build_docs.prepare_site(preview=True) + + assert source.read_text() == source_text + + +@pytest.mark.parametrize("preview", [False, True]) +def test_executable_markdown_requires_notebook_source( + executable_docs: Any, preview: bool +) -> None: + yaml = pytest.importorskip("yaml") + + build_docs = executable_docs + (build_docs.SOURCE / "example.ipynb").unlink() + (build_docs.SOURCE / "example.md").write_text( + "---\nkernelspec:\n name: python3\n display_name: Python 3\n---\n" + '# Example\n\n```{code-cell} python\nprint("RESULT")\n```\n' + ) + config_path = build_docs.SOURCE / "myst.yml" + config = yaml.safe_load(config_path.read_text()) + config["project"]["toc"] = [{"file": "example.md"}] + config_path.write_text(yaml.safe_dump(config)) + + with pytest.raises(ValueError, match=r"\.ipynb") as error: + build_docs.prepare_site(preview=preview) + + assert "example.md" in str(error.value) + + +@pytest.mark.parametrize( + ("base_url", "prefix"), + [ + (None, ""), + ("", ""), + ("/", ""), + ("/CyteType", "/CyteType"), + ("/CyteType/", "/CyteType"), + ("/docs/CyteType/", "/docs/CyteType"), + ], +) +def test_prepared_sidebar_urls_respect_site_base_url( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + base_url: str | None, + prefix: str, +) -> None: + pytest.importorskip("jupytext") + pytest.importorskip("nbformat") + yaml = pytest.importorskip("yaml") + from docs import build_docs + + source = tmp_path / "source" + source.mkdir() + (source / "index.md").write_text("# Example\n") + unchanged_urls = [ + "https://example.org/api/", + "//example.org/api/", + "mailto:docs@example.org", + "relative.html", + "#section", + ] + config = { + "version": 1, + "project": { + "github": "https://github.com/example/docs", + "toc": [ + {"file": "index.md"}, + {"url": "/quickstart#section"}, + { + "title": "Reference", + "children": [ + {"url": "/api/index.html", "open_in_same_tab": True}, + *({"url": url} for url in unchanged_urls), + ], + }, + ], + }, + } + source_config = yaml.safe_dump(config) + (source / "myst.yml").write_text(source_config) + monkeypatch.setattr(build_docs, "SOURCE", source) + monkeypatch.setattr(build_docs, "SITE", tmp_path / "site") + if base_url is None: + monkeypatch.delenv("BASE_URL", raising=False) + else: + monkeypatch.setenv("BASE_URL", base_url) + + site = build_docs.prepare_site() + toc = yaml.safe_load((site / "myst.yml").read_text())["project"]["toc"] + + assert toc[0] == {"file": "index.md"} + assert toc[1]["url"] == f"{prefix}/quickstart#section" + assert toc[2]["children"][0] == { + "url": f"{prefix}/api/index.html", + "open_in_same_tab": True, + } + assert [entry["url"] for entry in toc[2]["children"][1:]] == unchanged_urls + assert (source / "myst.yml").read_text() == source_config + + +@pytest.mark.parametrize( + ("page", "link", "expected"), + [ + ( + "guides/prepare-data.md", + "../reference/api/index.html", + "/api/index.html", + ), + ( + "guides/prepare-data.md", + "../reference/api/cytetype.html#cytetype.CyteType.run", + "/api/cytetype.html#cytetype.CyteType.run", + ), + ( + "results/report.md", + "../reference/api/helpers.md#cytetype.marker_dotplot", + "/api/helpers.html#cytetype.marker_dotplot", + ), + ("index.md", "reference/api/exceptions.html", "/api/exceptions.html"), + ("index.md", "/api/index.html", "/api/index.html"), + ("index.md", "reference/r-api.md", "reference/r-api.md"), + ( + "guides/automation.md", + "../reference/cli.md#setup", + "../reference/cli.md#setup", + ), + ("index.md", "https://example.org/reference/api/x.html", None), + ("index.md", "#reference", None), + ], +) +def test_relative_api_links_point_to_the_sphinx_pages( + page: str, link: str, expected: str | None +) -> None: + pytest.importorskip("jupytext") + pytest.importorskip("nbformat") + pytest.importorskip("yaml") + from docs import build_docs + + text = f"See [the API]({link}) and [more]({link}).\n" + + rewritten = build_docs.rewrite_api_links(text, Path(page)) + + target = link if expected is None else expected + assert rewritten == f"See [the API]({target}) and [more]({target}).\n" + + +def test_staged_pages_link_to_the_api_without_changing_sources( + executable_docs: Any, +) -> None: + nbformat = pytest.importorskip("nbformat") + yaml = pytest.importorskip("yaml") + + build_docs = executable_docs + guide = build_docs.SOURCE / "guides" / "guide.md" + guide.parent.mkdir() + guide_text = ( + "---\ndescription: A guide.\n---\n\n# Guide\n\n" + "Read [run()](../reference/api/cytetype.html#cytetype.CyteType.run).\n" + ) + guide.write_text(guide_text) + source = build_docs.SOURCE / "example.ipynb" + notebook = nbformat.read(source, as_version=4) + notebook.cells[0].source = "# Example\n\nSee the [API](reference/api/index.html)." + nbformat.write(notebook, source) + notebook_text = source.read_text() + + site = build_docs.prepare_site() + + staged_guide = (site / "guides" / "guide.md").read_text() + assert "[run()](/api/cytetype.html#cytetype.CyteType.run)" in staged_guide + assert yaml.safe_load(staged_guide.split("---", 2)[1])["description"] == "A guide." + staged = nbformat.read(site / "example.ipynb", as_version=4) + assert staged.cells[0].source == "# Example\n\nSee the [API](/api/index.html)." + assert guide.read_text() == guide_text + assert source.read_text() == notebook_text + + +@pytest.mark.parametrize("figure_count", [1, 2]) +def test_staged_figure_uses_authored_alt_without_changing_saved_output( + executable_docs: Any, + figure_count: int, +) -> None: + nbformat = pytest.importorskip("nbformat") + + build_docs = executable_docs + source = build_docs.SOURCE / "example.ipynb" + notebook = nbformat.read(source, as_version=4) + image = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aD1sAAAAASUVORK5CYII=" + descriptions = [ + "UMAP of the PBMC clusters, colored by CyteType annotation.", + "Dot plot of the marker genes that support each label.", + ][:figure_count] + notebook.cells[1].metadata["figure_descriptions"] = descriptions + notebook.cells[1].outputs = [ + nbformat.v4.new_output( + "display_data", + data={ + "image/png": image, + "text/plain": "
", + }, + ) + for _ in descriptions + ] + nbformat.write(notebook, source) + original = source.read_bytes() + + site = build_docs.prepare_site() + + staged = nbformat.read(site / "example.ipynb", as_version=4) + assert [ + output.data["text/plain"] for output in staged.cells[1].outputs + ] == descriptions + assert all(output.data["image/png"] == image for output in staged.cells[1].outputs) + assert build_docs.code_fingerprint(staged) == notebook.metadata.executed_code_sha256 + assert source.read_bytes() == original + + +@pytest.mark.parametrize( + "descriptions", + [None, [], [""], [" "], ["First", "Unexpected second"], "not a list"], +) +def test_staged_figure_rejects_missing_description( + executable_docs: Any, descriptions: object +) -> None: + nbformat = pytest.importorskip("nbformat") + + build_docs = executable_docs + source = build_docs.SOURCE / "example.ipynb" + notebook = nbformat.read(source, as_version=4) + notebook.cells[1].outputs = [ + nbformat.v4.new_output( + "display_data", + data={"image/png": "aW1hZ2U=", "text/plain": "
"}, + ) + ] + if descriptions is not None: + notebook.cells[1].metadata["figure_descriptions"] = descriptions + nbformat.write(notebook, source) + + with pytest.raises(ValueError, match="example.ipynb.*metadata.figure_descriptions"): + build_docs.prepare_site() + + +@pytest.fixture(params=["", "/CyteType"]) +def rendered_discovery_files( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest +) -> tuple[Path, Path, str]: + pytest.importorskip("jupytext") + pytest.importorskip("nbformat") + pytest.importorskip("yaml") + base_url = request.param + monkeypatch.setenv("BASE_URL", base_url) + html = tmp_path / "_build" / "html" + api_text = tmp_path / "api-text" + for directory in (html, api_text, html / "api" / "_sources"): + directory.mkdir(parents=True) + + references = [] + for name, route in (("index", "/"), ("quickstart", "/quickstart")): + references.append({"kind": "page", "url": route, "data": f"/{name}.json"}) + children = ( + [ + { + "type": "outputs", + "id": "saved-result", + "children": [ + { + "type": "output", + "jupyter_data": { + "output_type": "execute_result", + "data": { + "text/plain": {"content": "cell_count\n 1000"} + }, + }, + } + ], + } + ] + if name == "quickstart" + else [] + ) + (html / f"{name}.json").write_text( + json.dumps( + { + "frontmatter": {"title": name}, + "mdast": {"type": "root", "children": children}, + } + ) + ) + saved_output = ( + '
Loading...' + '

LEAKED_OUTPUT

' + if name == "quickstart" + else "" + ) + page = html / route.strip("/") / "index.html" + page.parent.mkdir(parents=True, exist_ok=True) + page.write_text( + f"{name}" + f"

{name}

" + "

Saved result: 42 cells.

" + "
def count():\n    return 42\n\nprint(count())\n42
" + "" + "
GroupCells
T cells42
" + '

Read the details.

' + 'Cell embedding' + f"{saved_output}" + "

Analysis continues after the saved output.

" + "
Footer navigation
" + ) + references.extend( + [ + { + "kind": "page", + "identifier": "quickstart", + "url": "/quickstart", + "data": "/quickstart.json", + }, + { + "kind": "heading", + "identifier": "results", + "url": "/quickstart", + "data": "/quickstart.json", + }, + ] + ) + (html / "myst.xref.json").write_text(json.dumps({"references": references})) + (html / "api" / "_sources" / "cytetype.md").write_text( + "# CyteType class\n\n```{autoclass} cytetype.CyteType\n:members:\n```\n" + ) + (html / "api" / "cytetype.html").write_text( + '' + "CyteType(adata, group_key, rank_key='rank_genes_groups')" + ) + (api_text / "cytetype.txt").write_text( + "CyteType class\n**************\n\n" + "class cytetype.CyteType(adata, group_key, rank_key='rank_genes_groups')\n" + " Prepare an AnnData object for annotation.\n" + ) + for relative in ( + "build/api/cytetype.html", + "api/search.html", + "api/genindex.html", + ): + page = html / relative + page.parent.mkdir(parents=True, exist_ok=True) + page.write_text("Extra generated page") + (html / "llms.txt").write_text( + "# CyteType\n\n- [Home](./): overview\n" + "\n## Getting started\n\n" + "- [Quickstart (Python)](quickstart/): first analysis\n" + "\n## Reference\n\n" + "- [CyteType class](api/cytetype.html): public API\n" + ) + (html / "robots.txt").write_text( + "User-agent: *\nAllow: /\nSitemap: http://localhost:3000/sitemap.xml\n" + ) + (html / "sitemap.xml").write_text( + '' + "http://localhost:3000/quickstart" + ) + return html, api_text, base_url + + +def _discovery_links(page: Path) -> list[dict[str, str | None]]: + class LinkParser(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.links: list[dict[str, str | None]] = [] + + def handle_starttag( + self, tag: str, attrs: list[tuple[str, str | None]] + ) -> None: + if tag == "link": + self.links.append(dict(attrs)) + + parser = LinkParser() + parser.feed(page.read_text()) + return parser.links + + +def test_discovery_metadata_uses_public_urls_and_local_export_links( + rendered_discovery_files: tuple[Path, Path, str], +) -> None: + from docs import build_docs + + html, api_text, base_url = rendered_discovery_files + build_docs.write_discovery_files(html, api_text, revision="0123456789ab") + + public = "https://docs.nygen.io/CyteType" + pages = [ + ("index.html", "/", "/index.txt", "text/plain"), + ( + "quickstart/index.html", + "/quickstart/", + "/quickstart/index.txt", + "text/plain", + ), + ( + "api/cytetype.html", + "/api/cytetype.html", + "/api/cytetype.txt", + "text/plain", + ), + ] + for page, route, export, media_type in pages: + links = _discovery_links(html / page) + canonicals = [link for link in links if link.get("rel") == "canonical"] + assert len(canonicals) == 1 + assert canonicals[0]["href"] == f"{public}{route}" + descriptions = [link for link in links if link.get("rel") == "describedby"] + assert len(descriptions) == 1 + assert descriptions[0]["href"] == f"{base_url}/llms.txt" + alternatives = [ + link + for link in links + if link.get("rel") == "alternate" and link.get("type") == media_type + ] + assert len(alternatives) == 1 + assert alternatives[0]["href"] == f"{base_url}{export}" + assert "

quickstart

" in (html / "quickstart" / "index.html").read_text() + + sitemap = ElementTree.parse(html / "sitemap.xml") + namespace = "{http://www.sitemaps.org/schemas/sitemap/0.9}" + assert sitemap.getroot().tag == f"{namespace}urlset" + locations = [ + element.text for element in sitemap.findall(f"{namespace}url/{namespace}loc") + ] + assert len(locations) == 3 + assert set(locations) == {f"{public}{route}" for _, route, _, _ in pages} + robots = (html / "robots.txt").read_text() + assert f"Sitemap: {public}/sitemap.xml" in robots + assert "http://localhost" not in robots + assert "User-agent: *" in robots + assert "Allow: /" in robots + + +def test_discovery_exports_preserve_content_and_identify_revision( + rendered_discovery_files: tuple[Path, Path, str], +) -> None: + from docs import build_docs + + html, api_text, _ = rendered_discovery_files + revision = "0123456789ab" + build_docs.write_discovery_files(html, api_text, revision=revision) + + for relative in ("index.txt", "quickstart/index.txt", "api/cytetype.txt"): + exported = (html / relative).read_text() + assert revision in exported + assert "{autoclass}" not in exported + for relative in ("index.txt", "quickstart/index.txt"): + exported = (html / relative).read_text() + assert "Saved result: 42 cells." in exported + assert "def count():\n return 42\n\nprint(count())\n42" in exported + assert all(value in exported for value in ("Group", "Cells", "T cells", "42")) + assert "Site navigation" not in exported + assert "Footer navigation" not in exported + quickstart = (html / "quickstart" / "index.txt").read_text() + assert "https://docs.nygen.io/CyteType/quickstart/details#reading" in quickstart + assert "https://docs.nygen.io/CyteType/quickstart/plot.png" in quickstart + assert "Cell embedding" in quickstart + assert quickstart.count("cell_count\n 1000") == 1 + assert "Loading..." not in quickstart + assert "LEAKED_OUTPUT" not in quickstart + assert "Analysis continues after the saved output." in quickstart + api = (html / "api" / "cytetype.txt").read_text() + assert ( + "class cytetype.CyteType(adata, group_key, rank_key='rank_genes_groups')\n" + " Prepare an AnnData object for annotation." in api + ) + llms = (html / "llms.txt").read_text() + assert revision in llms + links = dict(re.findall(r"\[([^\]]+)\]\(([^)]+)\)", llms)) + assert { + label: links[label] + for label in ("Home", "Quickstart (Python)", "CyteType class") + } == { + "Home": "https://docs.nygen.io/CyteType/index.txt", + "Quickstart (Python)": "https://docs.nygen.io/CyteType/quickstart/index.txt", + "CyteType class": "https://docs.nygen.io/CyteType/api/cytetype.txt", + } + + +def test_discovery_rejects_missing_api_text( + rendered_discovery_files: tuple[Path, Path, str], +) -> None: + from docs import build_docs + + html, api_text, _ = rendered_discovery_files + path = api_text / "cytetype.txt" + path.unlink() + + with pytest.raises(FileNotFoundError, match=re.escape(path.name)): + build_docs.write_discovery_files(html, api_text, revision="0123456789ab") + + +@pytest.mark.parametrize( + "content", ["

Navigation without an article

", "
"] +) +def test_discovery_rejects_missing_or_empty_article( + rendered_discovery_files: tuple[Path, Path, str], content: str +) -> None: + from docs import build_docs + + html, api_text, _ = rendered_discovery_files + (html / "quickstart" / "index.html").write_text( + f"{content}" + ) + + with pytest.raises(ValueError, match="quickstart"): + build_docs.write_discovery_files(html, api_text, revision="0123456789ab") + + +def test_text_export_preserves_saved_markdown_instead_of_object_repr( + rendered_discovery_files: tuple[Path, Path, str], +) -> None: + from docs import build_docs + + html, api_text, _ = rendered_discovery_files + document = json.loads((html / "quickstart.json").read_text()) + output = document["mdast"]["children"][0]["children"][0]["jupyter_data"] + markdown = "| Dataset | Cells |\n| --- | ---: |\n| PBMC | 1000 |" + output["data"] = { + "text/plain": {"content": ""}, + "text/markdown": {"content": markdown}, + } + (html / "quickstart.json").write_text(json.dumps(document)) + + build_docs.write_discovery_files(html, api_text, revision="0123456789ab") + + exported = (html / "quickstart" / "index.txt").read_text() + assert markdown in exported + assert "" not in exported + + +def test_text_export_uses_job_progress_text_instead_of_object_repr( + rendered_discovery_files: tuple[Path, Path, str], +) -> None: + from docs import build_docs + + html, api_text, _ = rendered_discovery_files + document = json.loads((html / "quickstart.json").read_text()) + outputs = document["mdast"]["children"][0]["children"] + outputs.append( + { + "type": "block", + "jupyter_data": { + "output_type": "display_data", + "data": { + "text/html": { + "content": "
[DONE] CyteType job "
+                        "completed & results <stored>.
" + }, + "text/plain": {"content": ""}, + }, + }, + } + ) + (html / "quickstart.json").write_text(json.dumps(document)) + + build_docs.write_discovery_files(html, api_text, revision="0123456789ab") + + exported = (html / "quickstart" / "index.txt").read_text() + assert "[DONE] CyteType job completed & results ." in exported + assert "" not in exported + assert " None: + from docs.render_outputs import render_outputs + + content = ( + '
' + '' + '' + "
Counts
Donor A1000<5
12006
" + "

2 rows Γ— 2 columns

" + ) + output = { + "output_type": "execute_result", + "execution_count": 1, + "metadata": {}, + "data": { + "text/html": [content] if as_lines else content, + "text/plain": "Counts\nDonor A 1000 <5\n 1200 6", + }, + } + node: dict[str, Any] = { + "type": "output", + "jupyter_data": deepcopy(output), + "children": [], + } + + render_outputs(node) + + assert node["type"] == "block" + assert node["jupyter_data"] == output + table, summary = node["children"] + assert table["type"] == "table" + assert len(table["children"]) == 3 + heading = table["children"][0]["children"][1] + assert heading == { + "type": "tableCell", + "header": True, + "colspan": 2, + "children": [{"type": "text", "value": "Counts"}], + } + row = table["children"][1]["children"] + assert row[0]["header"] is True + assert row[0]["rowspan"] == 2 + assert row[1]["header"] is False + assert row[1]["children"][0]["value"] == "1000" + assert row[2]["children"][0]["value"] == "<5" + assert summary == { + "type": "paragraph", + "children": [{"type": "text", "value": "2 rows Γ— 2 columns"}], + } + + +def test_saved_output_transform_preserves_streams_and_renders_timing() -> None: + from docs.render_outputs import render_outputs + + timing = { + "type": "output", + "jupyter_data": { + "output_type": "display_data", + "metadata": {"cytearc_execution_timing": True}, + "data": { + "text/html": "Wall time: 1.234 s", + "text/plain": ["Wall time: ", "1.234 s"], + }, + }, + "children": [], + } + stream = { + "type": "output", + "jupyter_data": {"output_type": "stream", "name": "stdout", "text": "Done"}, + "children": [], + } + tree: dict[str, Any] = { + "type": "outputs", + "children": [deepcopy(stream), deepcopy(timing)], + } + + render_outputs(tree) + + assert tree["children"][0] == stream + rendered = tree["children"][1] + assert rendered["jupyter_data"] == timing["jupyter_data"] + assert rendered["children"] == [ + { + "type": "paragraph", + "children": [{"type": "text", "value": "Wall time: 1.234 s"}], + } + ] + + +@pytest.mark.parametrize( + ("content", "text"), + [ + ( + "
"
+            "β ‹ CyteType job running... 01:05 elapsed
", + "β ‹ CyteType job running... 01:05 elapsed", + ), + ("
[DONE] CyteType job completed.
", "[DONE] CyteType job completed."), + ( + "
Cluster <1> & "2" '3'
", + "Cluster <1> & \"2\" '3'", + ), + (" \n
\nFirst line\n  Indented\n
\n", "First line\n Indented\n"), + (["
Split ", "across lines
"], "Split across lines"), + ("
Upper case
", "Upper case"), + ("
", ""),
+    ],
+)
+def test_single_pre_output_becomes_a_text_code_block(
+    content: str | list[str], text: str
+) -> None:
+    from docs.render_outputs import render_outputs
+
+    output = {
+        "output_type": "display_data",
+        "metadata": {},
+        "data": {
+            "text/html": content,
+            "text/plain": "",
+        },
+    }
+    node: dict[str, Any] = {
+        "type": "output",
+        "jupyter_data": deepcopy(output),
+        "children": [],
+    }
+
+    render_outputs(node)
+
+    assert node["type"] == "block"
+    assert node["jupyter_data"] == output
+    assert node["children"] == [{"type": "code", "lang": "text", "value": text}]
+
+
+def test_cytetype_progress_display_renders_as_its_message() -> None:
+    pytest.importorskip("IPython")
+    from cytetype.api.progress import _render_notebook_message
+    from docs.render_outputs import render_outputs
+
+    message = '[FAILED] CyteType job failed.  & retry'
+    html = _render_notebook_message(message)
+    node: dict[str, Any] = {
+        "type": "output",
+        "jupyter_data": {
+            "output_type": "display_data",
+            "data": {"text/html": html.data, "text/plain": repr(html)},
+        },
+        "children": [],
+    }
+
+    render_outputs(node)
+
+    assert node["children"] == [{"type": "code", "lang": "text", "value": message}]
+
+
+@pytest.mark.parametrize(
+    "content",
+    [
+        "
", + "
Unclosed", + '
Invalid span
', + "
Unrecognized output
", + "
One
Two
", + "
Nested markup
", + "
Text
after", + "Before
Text
", + "
Unclosed",
+        "
Text
", + "
Text

", + "
Table follows
1
", + ], +) +def test_saved_output_transform_rejects_unsupported_or_invalid_html( + content: str, +) -> None: + from docs.render_outputs import render_outputs + + node = { + "type": "output", + "jupyter_data": {"data": {"text/html": content}}, + "children": [], + } + + with pytest.raises(ValueError): + render_outputs(node) + + +def test_render_plugin_follows_the_myst_executable_protocol() -> None: + plugin = _DOCS / "render_outputs.py" + specification = subprocess.run( + [sys.executable, str(plugin)], capture_output=True, text=True, check=True + ) + assert json.loads(specification.stdout) == { + "name": "Saved notebook outputs", + "transforms": [{"name": "saved-outputs", "stage": "document"}], + } + tree = { + "type": "root", + "children": [ + { + "type": "output", + "jupyter_data": { + "output_type": "display_data", + "data": {"text/html": "
[DONE] CyteType job completed.
"}, + }, + "children": [], + } + ], + } + transformed = subprocess.run( + [sys.executable, str(plugin), "--transform", "saved-outputs"], + input=json.dumps(tree), + capture_output=True, + text=True, + check=True, + ) + assert json.loads(transformed.stdout)["children"][0]["children"] == [ + {"type": "code", "lang": "text", "value": "[DONE] CyteType job completed."} + ] + assert os.access(plugin, os.X_OK), "MyST runs the plugin as an executable" + + +def _git(repository: Path, *arguments: str, date: str | None = None) -> None: + """Run git in a test repository, isolated from the user's configuration.""" + environment = { + **os.environ, + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_AUTHOR_NAME": "Documentation tests", + "GIT_AUTHOR_EMAIL": "docs@example.org", + "GIT_COMMITTER_NAME": "Documentation tests", + "GIT_COMMITTER_EMAIL": "docs@example.org", + } + if date is not None: + environment["GIT_AUTHOR_DATE"] = environment["GIT_COMMITTER_DATE"] = date + subprocess.run( + ["git", *arguments], + cwd=repository, + env=environment, + capture_output=True, + check=True, + ) + + +@pytest.fixture +def documentation_git_checkout(tmp_path: Path) -> Path: + pytest.importorskip("jupytext") + pytest.importorskip("nbformat") + pytest.importorskip("yaml") + repository = tmp_path / "checkout" + repository.mkdir() + _git(repository, "init", "--quiet") + files = { + "README.md": "# Example\n", + "LICENSE": "Example license\n", + "docs/source/reference/license.md": "# License\n", + "docs/source/references.bib": "@article{paper, title={Paper}}\n", + } + # Each file has its own commit and date, so the latest one is unambiguous. + for day, (relative, contents) in enumerate(files.items(), start=1): + path = repository / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(contents) + _git(repository, "add", relative) + _git( + repository, + "commit", + "--quiet", + "--message", + f"Add {relative}", + date=f"2026-10-0{day}T12:00:00+02:00", + ) + return repository + + +def test_last_modified_uses_source_and_include_history( + documentation_git_checkout: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from docs import build_docs + + repository = documentation_git_checkout + monkeypatch.setattr(build_docs, "SOURCE", repository / "docs/source") + monkeypatch.setattr(build_docs, "SITE", repository / "docs/build/site") + bibliography = repository / "docs/source/references.bib" + source = repository / "docs/source/reference/license.md" + dependencies = build_docs.myst_source_files( + { + "location": "/reference/license.md", + "dependencies": [], + "mdast": { + "type": "root", + "children": [ + {"type": "include", "file": "../references.bib"}, + { + "type": "include", + "file": "../../../../README.md", + "children": [{"type": "include", "file": "LICENSE"}], + }, + {"type": "cite", "label": "paper"}, + ], + }, + }, + {bibliography}, + ) + assert dependencies == { + source, + bibliography, + repository / "README.md", + repository / "LICENSE", + } + dates = [ + subprocess.run( + ["git", "log", "-1", "--format=%cI", "--", str(path)], + cwd=repository, + capture_output=True, + text=True, + check=True, + ).stdout.strip() + for path in dependencies + ] + expected = max(dates, key=datetime.fromisoformat) + assert build_docs.latest_source_commit(repository, dependencies) == expected + for path in dependencies: + path.touch() + assert build_docs.latest_source_commit(repository, dependencies) == expected + + +@pytest.mark.parametrize( + "change", ["unstaged", "staged", "untracked", "deleted", "outside"] +) +def test_last_modified_omits_uncommitted_or_untracked_dependencies( + documentation_git_checkout: Path, change: str +) -> None: + from docs import build_docs + + repository = documentation_git_checkout + source = repository / "LICENSE" + dependencies = {source} + if change in {"unstaged", "staged"}: + source.write_text(source.read_text() + "\nUncommitted change\n") + if change == "staged": + _git(repository, "add", "LICENSE") + elif change == "untracked": + include = repository / "untracked.txt" + include.write_text("Untracked included content\n") + dependencies.add(include) + elif change == "deleted": + source.unlink() + else: + dependencies.add(repository.parent / "outside.txt") + assert build_docs.latest_source_commit(repository, dependencies) is None + + +def test_last_modified_ignores_unrelated_working_tree_changes( + documentation_git_checkout: Path, +) -> None: + from docs import build_docs + + repository = documentation_git_checkout + dependencies = {repository / "LICENSE"} + original = build_docs.latest_source_commit(repository, dependencies) + assert original is not None + (repository / "README.md").write_text("Unrelated local change\n") + assert build_docs.latest_source_commit(repository, dependencies) == original + + +def test_last_modified_tracks_lazy_api_implementations() -> None: + addnodes = pytest.importorskip("sphinx.addnodes") + pytest.importorskip("jupytext") + pytest.importorskip("nbformat") + pytest.importorskip("yaml") + from docs import build_docs + + doctree = addnodes.desc() + for module, fullname, kind in ( + ("cytetype", "marker_dotplot", "function"), + ("cytetype", "CyteType.run", "method"), + ("cytetype", "CyteTypeError.message", "attribute"), + ("cytetype", "CyteTypeTimeoutError", "exception"), + ("cytetype.api", "LLMModelConfig.provider", "attribute"), + ("cytetype", "subsample_by_group", "function"), + ): + description = addnodes.desc(objtype=kind) + description += addnodes.desc_signature(module=module, fullname=fullname) + doctree += description + paths = build_docs.api_source_files(doctree) + package = Path(__file__).resolve().parents[1] / "cytetype" + assert paths == { + package / "plotting" / "dotplot.py", + package / "main.py", + package / "api" / "exceptions.py", + package / "api" / "schemas.py", + package / "preprocessing" / "subsampling.py", + } + + +def test_sitemap_includes_only_known_last_modified_dates( + rendered_discovery_files: tuple[Path, Path, str], +) -> None: + from docs import build_docs + + html, api_text, _ = rendered_discovery_files + date = "2026-10-08T20:02:08+02:00" + build_docs.write_discovery_files( + html, api_text, revision="0123456789ab", last_modified={"/quickstart/": date} + ) + namespace = "{http://www.sitemaps.org/schemas/sitemap/0.9}" + entries = ElementTree.parse(html / "sitemap.xml").findall(f"{namespace}url") + dated = { + entry.findtext(f"{namespace}loc"): entry.findtext(f"{namespace}lastmod") + for entry in entries + if entry.find(f"{namespace}lastmod") is not None + } + assert dated == {"https://docs.nygen.io/CyteType/quickstart/": date} + + +def test_staged_config_paths_are_relative_to_the_site(executable_docs: Any) -> None: + yaml = pytest.importorskip("yaml") + + # The theme embeds the project configuration in every page, so absolute paths + # would publish the paths of the machine that built the site. + build_docs = executable_docs + (build_docs.SOURCE / "llms.txt").write_text("# Example\n") + config_path = build_docs.SOURCE / "myst.yml" + config = yaml.safe_load(config_path.read_text()) + config["project"]["static_files"] = ["llms.txt", "../build/api"] + config["project"]["plugins"] = [ + {"type": "executable", "path": "../render_outputs.py"} + ] + config_path.write_text(yaml.safe_dump(config)) + + site = build_docs.prepare_site() + + staged = yaml.safe_load((site / "myst.yml").read_text())["project"] + paths = [*staged["static_files"], staged["plugins"][0]["path"]] + originals = ["llms.txt", "../build/api", "../render_outputs.py"] + for path, original in zip(paths, originals, strict=True): + assert not Path(path).is_absolute(), path + assert (site / path).resolve() == (build_docs.SOURCE / original).resolve() diff --git a/tests/test_docs_structure.py b/tests/test_docs_structure.py new file mode 100644 index 0000000..c186829 --- /dev/null +++ b/tests/test_docs_structure.py @@ -0,0 +1,368 @@ +"""Structure checks for the documentation sources in docs/source.""" + +import io +import json +import re +import shutil +import subprocess +import tokenize +from pathlib import Path +from typing import Any, get_args + +import pytest + +yaml = pytest.importorskip("yaml") + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +_DOCS_SOURCE = _REPOSITORY_ROOT / "docs" / "source" +_API_SOURCE = _DOCS_SOURCE / "reference" / "api" +_MARKDOWN_LINK = re.compile(r"\[[^\]]*\]\(([^)\s]+)\)") +_AUTODOC_TARGET = re.compile( + r"(?m)^(?:\.\. (?:auto\w+|py:\w+)::|[`:]{3,}\{(?:auto\w+|py:\w+)\})[ \t]*([^\s(]+)" +) +_TAB_SET = re.compile( + r"(?ms)^(?P:{4,}|`{4,})\{tab-set\}[^\n]*\n(?P.*?)^(?P=fence)[ \t]*$" +) +_TAB_ITEM = re.compile( + r"(?m)^(?::{3,}|`{3,})\{tab-item\}[ \t]*(?P[^\n]*)\n" + r"(?P<options>(?:[ \t]*:[\w-]+:[^\n]*\n)*)" +) +_CLIENT_TABS = {"Python": "python", "R": "r"} +_SECTIONS = [ + "Getting started", + "Prepare and run", + "Results", + "Platform", + "Reference", + "Support", +] + + +def _project() -> dict[str, Any]: + project: dict[str, Any] = yaml.safe_load((_DOCS_SOURCE / "myst.yml").read_text())[ + "project" + ] + return project + + +def _notebook_cells(path: Path) -> list[dict[str, Any]]: + cells: list[dict[str, Any]] = json.loads(path.read_text())["cells"] + for cell in cells: + if not isinstance(cell["source"], str): + cell["source"] = "".join(cell["source"]) + return cells + + +def _source_text(path: Path) -> str: + if path.suffix != ".ipynb": + return path.read_text() + return "\n".join(cell["source"] for cell in _notebook_cells(path)) + + +def _pages() -> list[Path]: + return sorted( + path + for path in _DOCS_SOURCE.rglob("*") + if path.suffix in {".md", ".ipynb"} + and not {"_build", ".ipynb_checkpoints"} & set(path.parts) + ) + + +def _api_pages() -> list[Path]: + return sorted(_API_SOURCE.glob("*.md")) + + +def _guide_pages() -> list[Path]: + """Return the MyST pages; Sphinx builds the pages in reference/api.""" + return [page for page in _pages() if not page.is_relative_to(_API_SOURCE)] + + +def _toc_entries(entries: list[dict[str, Any]]) -> list[dict[str, Any]]: + found = [] + for entry in entries: + found.append(entry) + found.extend(_toc_entries(entry.get("children", []))) + return found + + +def _toc_files() -> set[str]: + return { + entry["file"] for entry in _toc_entries(_project()["toc"]) if "file" in entry + } + + +def _slug(name: str) -> str: + return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + + +def _route(page: Path) -> str: + """Return the MyST route of a page, such as ``getting-started/quickstart``.""" + relative = page.relative_to(_DOCS_SOURCE).with_suffix("") + if relative.as_posix() == "index": + return "" + return "/".join(_slug(part) for part in relative.parts) + + +def _route_source(route: str) -> Path: + """Return the source page published at ``route``, such as ``api/cytetype.html``.""" + route = route.split("#", maxsplit=1)[0] + if route.startswith("api/"): + name = route.removeprefix("api/") + assert name.endswith(".html"), route + return _API_SOURCE / name.replace(".html", ".md") + routes = {_route(page): page for page in _guide_pages()} + route = "" if route in {"", "./"} else route.strip("/") + assert route in routes, f"No documentation page publishes {route!r}" + return routes[route] + + +def _link_source(page: Path, target: str) -> Path: + """Return the documentation source that a relative link from ``page`` opens.""" + path = target.split("#", maxsplit=1)[0] + if path.startswith("/"): + return _route_source(path.lstrip("/")) + if page.parent == _API_SOURCE: + # Sphinx pages are served at /api/<page>.html, beside the MyST routes. + if path.startswith("../"): + return _route_source(path.removeprefix("../")) + return _API_SOURCE / path.replace(".html", ".md") + resolved = (page.parent / path).resolve() + if resolved.parent == _API_SOURCE.resolve(): + # The build points links to the Sphinx sources at their /api/ pages. + assert resolved.suffix in {".html", ".md"}, (page, target) + return resolved.with_suffix(".md") + assert path.endswith((".md", ".ipynb")), (page, target) + return resolved + + +def _python_sources(path: Path) -> list[str]: + if path.suffix != ".ipynb": + return [] + return [ + cell["source"] + for cell in _notebook_cells(path) + if cell["cell_type"] == "code" and cell["source"].strip() + ] + + +def test_llms_index_links_to_every_published_page() -> None: + contents = (_DOCS_SOURCE / "llms.txt").read_text() + targets = _MARKDOWN_LINK.findall(contents) + + assert targets + assert contents.startswith("# CyteType\n\n> ") + for target in targets: + assert "://" not in target, target + # The build maps these exact routes to the plain-text exports. + assert target == "./" or target.endswith(("/", ".html")), target + source = _route_source(target) + assert source.resolve().is_relative_to(_DOCS_SOURCE.resolve()) + assert source.is_file(), target + listed = {_route_source(target).resolve() for target in targets} + published = {(_DOCS_SOURCE / page).resolve() for page in _toc_files()} + published |= {page.resolve() for page in _api_pages()} + assert published - listed == set(), "Pages missing from llms.txt" + + +def test_navigation_publishes_every_page() -> None: + project = _project() + toctree = project["toc"] + sections = { + entry["title"]: entry["children"] for entry in toctree if "children" in entry + } + + assert toctree[0] == {"file": "index.md"} + assert list(sections) == _SECTIONS + assert [entry["file"] for entry in sections["Getting started"][:3]] == [ + "getting-started/installation.md", + "getting-started/sign-in.md", + "getting-started/quickstart.ipynb", + ] + assert sections["Reference"][0] == { + "title": "Python API", + "url": "/api/index.html", + "open_in_same_tab": True, + } + issue_trackers = {entry["url"] for entry in sections["Support"] if "url" in entry} + assert issue_trackers == { + "https://github.com/NygenAnalytics/CyteType/issues", + "https://github.com/NygenAnalytics/CyteTypeR/issues", + } + assert {"llms.txt", "../build/api"} <= set(project["static_files"]) + assert "reference/api/**" in project["exclude"] + + published = _toc_files() + for file in published: + assert (_DOCS_SOURCE / file).is_file(), f"{file} is in the TOC but missing" + for page in _guide_pages(): + relative = page.relative_to(_DOCS_SOURCE).as_posix() + assert relative in published, f"{relative} is not in the TOC" + + +def test_relative_links_resolve_to_documentation_pages() -> None: + for page in _pages(): + for target in _MARKDOWN_LINK.findall(_source_text(page)): + if "://" in target or target.startswith(("#", "mailto:")): + continue + source = _link_source(page, target) + assert source.is_file(), (page, target) + assert source.resolve().is_relative_to(_DOCS_SOURCE.resolve()) + + +def test_pages_have_a_description_and_one_title() -> None: + for page in _guide_pages(): + if page.suffix == ".ipynb": + metadata = json.loads(page.read_text())["metadata"] + description = metadata.get("description") + text = "\n".join( + cell["source"] + for cell in _notebook_cells(page) + if cell["cell_type"] == "markdown" + ) + else: + text = page.read_text() + frontmatter = re.match(r"\A---\n(.*?)\n---\n", text, re.DOTALL) + assert frontmatter, f"{page} has no front matter" + description = (yaml.safe_load(frontmatter[1]) or {}).get("description") + text = text[frontmatter.end() :] + assert isinstance(description, str) and description.strip(), page + text = re.sub(r"(?ms)^(`{3,}|~{3,}).*?^\1", "", text) + assert len(re.findall(r"(?m)^# ", text)) == 1, f"{page} needs one H1 title" + + +def test_autodoc_targets_use_the_public_cytetype_names() -> None: + import cytetype + import cytetype.api + + # Document objects under the names users import them by, not their modules. + public = {"cytetype": cytetype.__all__, "cytetype.api": cytetype.api.__all__} + targets = [] + for page in _api_pages(): + for target in _AUTODOC_TARGET.findall(page.read_text()): + module, _, name = target.rpartition(".") + assert name in public.get(module, []), (page, target) + targets.append(target) + assert "cytetype.CyteType" in targets + + +def test_client_tabs_are_synced_with_python_first() -> None: + for page in _pages(): + text = _source_text(page) + tab_sets = list(_TAB_SET.finditer(text)) + assert len(tab_sets) == text.count("{tab-set}"), (page, "unparsed tab-set") + items = 0 + for tab_set in tab_sets: + titles = [] + for item in _TAB_ITEM.finditer(tab_set["body"]): + title = item["title"].strip() + options = dict( + re.findall( + r"(?m)^[ \t]*:([\w-]+):[ \t]*(.*?)[ \t]*$", item["options"] + ) + ) + sync = options.get("sync") + if title in _CLIENT_TABS or sync in _CLIENT_TABS.values(): + assert sync == _CLIENT_TABS.get(title), (page, title, sync) + titles.append(title) + items += 1 + if {"Python", "R"} & set(titles): + assert "Python" in titles and "R" in titles, (page, titles) + assert titles.index("Python") < titles.index("R"), (page, titles) + assert items == text.count("{tab-item}"), (page, "unparsed tab-item") + + +def test_notebook_comments_explain_each_step_on_their_own_lines() -> None: + notebooks = [page for page in _pages() if page.suffix == ".ipynb"] + assert notebooks + for notebook in notebooks: + for index, source in enumerate(_python_sources(notebook), start=1): + lines = source.splitlines() + assert lines[0].startswith("# "), (notebook.name, index) + tokens = tokenize.generate_tokens(io.StringIO(source).readline) + for token in tokens: + if token.type != tokenize.COMMENT: + continue + row = token.start[0] + assert token.line.strip().startswith("#"), (notebook.name, token.line) + previous = lines[row - 2].strip() if row > 1 else "" + assert row == 1 or not previous or previous.startswith("#"), ( + notebook.name, + index, + token.line, + ) + + +def test_pages_contain_no_html_comments() -> None: + # The site publishes page sources, page data and the search index, so comments leak. + for page in _pages(): + assert "<!--" not in _source_text(page), page + + +def test_notebook_outputs_contain_no_local_paths() -> None: + # Warnings can print paths of the machine that executed a page; outputs are published. + local_path = re.compile(r"/home/|/Users/|/tmp/|[A-Za-z]:\\\\Users\\\\") + for page in _pages(): + if page.suffix != ".ipynb": + continue + for index, cell in enumerate(_notebook_cells(page), start=1): + outputs = json.dumps(cell.get("outputs", [])) + assert not local_path.search(outputs), (page, index) + + +def test_notebooks_contain_no_api_keys_or_bearer_tokens() -> None: + # Notebooks are published with their outputs. Report only where a match is, so that + # a failure does not print the key. + secret = re.compile(r"cyt_[A-Za-z0-9_-]{16,}|Bearer ") + found = [] + for page in _pages(): + if page.suffix != ".ipynb": + continue + notebook = json.loads(page.read_text()) + if secret.search(json.dumps(notebook["metadata"])): + found.append((page.name, "metadata")) + for index, cell in enumerate(notebook["cells"], start=1): + if secret.search(json.dumps(cell)): + found.append((page.name, f"cell {index}")) + assert found == [] + + +def test_llm_configuration_reference_matches_the_schema() -> None: + from cytetype.api.schemas import AgentType, LLMModelConfig, LLMProvider + + text = (_API_SOURCE / "llm-config.md").read_text() + documented = re.findall( + r"(?m)^ \.\. py:attribute:: (\w+)\n((?: :\w+:.*\n)*)", text + ) + fields = LLMModelConfig.model_fields + + assert [name for name, _ in documented] == list(fields) + for name, options in documented: + field = fields[name] + values = dict(re.findall(r":(\w+): (.*)", options)) + if field.is_required(): + assert "value" not in values, name + else: + default = field.get_default(call_default_factory=True) + assert values.get("value") == repr(default), name + for provider in get_args(LLMProvider): + assert f"``{provider}``" in text, provider + for agent in get_args(AgentType): + assert f"``{agent}``" in text, agent + + +def test_documentation_notebooks_are_tracked() -> None: + git = shutil.which("git") + if git is None or not (_REPOSITORY_ROOT / ".git").exists(): + pytest.skip("Not a git checkout") + notebooks = [ + page.relative_to(_REPOSITORY_ROOT).as_posix() + for page in _pages() + if page.suffix == ".ipynb" + ] + ignored = subprocess.run( + [git, "check-ignore", "--no-index", *notebooks], + cwd=_REPOSITORY_ROOT, + capture_output=True, + text=True, + ) + assert ignored.stdout == "", f"Ignored notebooks: {ignored.stdout}" diff --git a/tests/test_logging.py b/tests/test_logging.py new file mode 100644 index 0000000..0da65c0 --- /dev/null +++ b/tests/test_logging.py @@ -0,0 +1,20 @@ +from typing import Any + +import pytest + +from cytetype.config import _log_format + + +def _record(level: str) -> Any: + return {"level": type("Level", (), {"name": level})()} + + +def test_success_lines_are_colored_by_default(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("NO_COLOR", raising=False) + assert _log_format(_record("SUCCESS")) == "\033[92mβœ“\033[0m {message}\n" + + +def test_no_color_removes_ansi_codes(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("NO_COLOR", "1") + assert _log_format(_record("SUCCESS")) == "βœ“ {message}\n" + assert _log_format(_record("INFO")) == "{message}\n" diff --git a/tests/test_public_api.py b/tests/test_public_api.py new file mode 100644 index 0000000..844fa6c --- /dev/null +++ b/tests/test_public_api.py @@ -0,0 +1,117 @@ +"""Public names, provider list and server error-code mapping.""" + +import builtins +from typing import Any + +import pytest +import requests +from pydantic import ValidationError + +import cytetype +import cytetype.api +from cytetype.api import exceptions +from cytetype.api.schemas import LLMModelConfig +from cytetype.api.transport import HTTPTransport + +EXCEPTION_NAMES = [ + "CyteTypeError", + "APIError", + "AuthenticationError", + "AuthorizationError", + "RateLimitError", + "QuotaExceededError", + "JobNotFoundError", + "JobFailedError", + "LLMValidationError", + "ClientDisconnectedError", + "NetworkError", +] + + +@pytest.mark.parametrize("name", EXCEPTION_NAMES) +def test_exceptions_are_exported_at_top_level(name: str) -> None: + assert name in cytetype.__all__ + assert getattr(cytetype, name) is getattr(exceptions, name) + + +def test_timeout_error_is_exported_without_shadowing_the_builtin() -> None: + assert cytetype.CyteTypeTimeoutError is exceptions.TimeoutError + assert "CyteTypeTimeoutError" in cytetype.__all__ + assert "TimeoutError" not in cytetype.__all__ + + namespace: dict[str, Any] = {} + exec("from cytetype import *\nresolved = TimeoutError", namespace) + assert "TimeoutError" not in namespace + assert namespace["resolved"] is builtins.TimeoutError + assert namespace["CyteTypeTimeoutError"] is exceptions.TimeoutError + + +def test_all_public_names_resolve() -> None: + for name in cytetype.__all__: + assert hasattr(cytetype, name), name + for name in cytetype.api.__all__: + assert hasattr(cytetype.api, name), name + + +def test_client_disconnected_error_is_exported_from_api() -> None: + assert "ClientDisconnectedError" in cytetype.api.__all__ + assert cytetype.api.ClientDisconnectedError is exceptions.ClientDisconnectedError + + +def test_api_star_import_exports_the_timeout_error_under_both_names() -> None: + from cytetype.api import TimeoutError as ApiTimeoutError + + assert ApiTimeoutError is exceptions.TimeoutError + assert cytetype.api.CyteTypeTimeoutError is exceptions.TimeoutError + assert "TimeoutError" in cytetype.api.__all__ + assert "CyteTypeTimeoutError" in cytetype.api.__all__ + + # Code written for earlier releases catches TimeoutError after the star import. + namespace: dict[str, Any] = {} + exec( + "from cytetype.api import *\n" + "try:\n" + " raise CyteTypeTimeoutError('Job did not complete')\n" + "except TimeoutError as error:\n" + " caught = error\n", + namespace, + ) + assert namespace["TimeoutError"] is exceptions.TimeoutError + assert namespace["CyteTypeTimeoutError"] is exceptions.TimeoutError + assert type(namespace["caught"]) is exceptions.TimeoutError + + +def test_authentication_required_maps_to_authentication_error() -> None: + error = exceptions.create_api_exception( + "AUTHENTICATION_REQUIRED", "Authentication is required." + ) + + assert type(error) is exceptions.AuthenticationError + assert error.error_code == "AUTHENTICATION_REQUIRED" + assert error.message == "Authentication is required." + + +def test_transport_raises_authentication_error_for_authentication_required() -> None: + response = requests.Response() + response.status_code = 401 + response._content = ( + b'{"detail": {"error_code": "AUTHENTICATION_REQUIRED", ' + b'"message": "Authentication is required."}}' + ) + + with pytest.raises(exceptions.AuthenticationError) as error_info: + HTTPTransport("https://api.test")._parse_error(response) + + assert error_info.value.error_code == "AUTHENTICATION_REQUIRED" + + +@pytest.mark.parametrize("provider", ["deepseek", "nebius", "together"]) +def test_new_llm_providers_are_accepted(provider: str) -> None: + config = LLMModelConfig(provider=provider, name="model", apiKey="key") # type: ignore[arg-type] + + assert config.provider == provider + + +def test_unknown_llm_provider_is_rejected() -> None: + with pytest.raises(ValidationError): + LLMModelConfig(provider="unknown", name="model", apiKey="key") # type: ignore[arg-type] diff --git a/tests/test_results_normalization.py b/tests/test_results_normalization.py new file mode 100644 index 0000000..bf8a788 --- /dev/null +++ b/tests/test_results_normalization.py @@ -0,0 +1,980 @@ +"""Normalization of /results annotations and how results are stored and read.""" + +import copy +import json +from pathlib import Path +from typing import Any + +import anndata +import numpy as np +import pandas as pd +import pytest + +from cytetype.api import client +from cytetype.api.normalization import ( + fill_cluster_annotation, + normalize_cluster_annotation, + renormalize_results, +) +from cytetype.api.transport import HTTPTransport +from cytetype.core.results import load_local_results, store_annotations +from cytetype.plotting import marker_dotplot + +LEGACY_FIELDS = ( + "justification", + "supportingMarkers", + "conflictingMarkers", + "missingExpression", + "unexpectedExpression", +) +EXISTING_KEYS = ( + "clusterId", + "annotation", + "ontologyTerm", + "ontologyTermID", + "granularAnnotation", + "cellState", + *LEGACY_FIELDS, +) +SDK_KEYS = ("reviewerConfidence", "isHeterogeneous", "heterogeneityType") +EMPTY_LEGACY_FIELDS: dict[str, Any] = { + "justification": "", + "supportingMarkers": [], + "conflictingMarkers": [], + "missingExpression": "", + "unexpectedExpression": "", +} +CONCLUSION = ( + "**CD8+ T cell**: `CD8A` and `CD8B` with cytotoxic `GZMK` support a\n" + "cytotoxic T cell identity. " +) +EXPECTED_FILL: dict[str, Any] = { + "justification": CONCLUSION, + "supportingMarkers": ["CD8A", "CD8B", "GZMK"], + "conflictingMarkers": ["MKI67"], + "missingExpression": "`CCR7` and `SELL` mark naive cells.", + "unexpectedExpression": "`MKI67` suggests a proliferating subset.", +} +LEGACY_EVIDENCE: dict[str, Any] = { + "justification": "Strong GFAP and S100B expression.", + "supportingMarkers": ["GFAP", "S100B", "AQP4"], + "conflictingMarkers": [], + "missingExpression": "SOX9 lower than expected", + "unexpectedExpression": "", +} + + +def _gen2_cell_type(**overrides: Any) -> dict[str, Any]: + """``fullOutput.cellType`` as stored between January and August 2026.""" + cell_type: dict[str, Any] = { + "label": "Cd8+ T Cell", + "keySupportingGenes": ["CD8A", "CD8B", "GZMK"], + "keySupportingGenesReasoning": "`CD8A` and `CD8B` are lineage markers.", + "missingGenes": ["CCR7", "SELL"], + "missingGenesReasoning": "`CCR7` and `SELL` mark naive cells.", + "unexpectedGenes": ["MKI67"], + "unexpectedGenesReasoning": "`MKI67` suggests a proliferating subset.", + "pathwaysSupportingEvidence": ["T cell receptor signaling"], + "pathwaysSupportingEvidenceReasoning": "TCR signaling is enriched.", + "candidateComparison": ["NK cell: lacks `NCAM1`."], + "tissueContextFit": "Expected in blood.", + "clusterContextFit": "Present in all samples.", + "conclusion": CONCLUSION, + "citationExplanations": {}, + } + cell_type.update(overrides) + return cell_type + + +def _v2_cell_type(**overrides: Any) -> dict[str, Any]: + """``fullOutput.cellType`` as stored since August 2026.""" + cell_type = _gen2_cell_type( + catalogReferenceLabel="CD8-positive, alpha-beta T cell", + classification="catalogue", + whyNotDeeper="", + annotationLevels={ + "L1": {"label": "Immune cell"}, + "L2": {"label": "T cell"}, + "L3": {"label": "CD8+ T cell"}, + }, + candidateComparison={"NK cell": {"verdict": "rejected"}}, + ) + del cell_type["citationExplanations"] + cell_type.update(overrides) + return cell_type + + +def _review( + confidence: Any, + is_heterogeneous: Any, + heterogeneity_type: Any = None, +) -> dict[str, Any]: + return { + "reviewComments": "", + "confidence": confidence, + "isHeterogeneous": is_heterogeneous, + "feedback": "", + "labelComparisonScore": None, + "fullOutput": { + "confidenceAssessment": {"label": confidence, "reasoning": ""}, + "heterogeneityAssessment": { + "label": "Heterogeneous" if is_heterogeneous else "Homogeneous", + "reasoning": "", + "heterogeneityType": heterogeneity_type, + "heterogeneityTypeReasoning": "", + }, + }, + } + + +def _new_result( + cluster_id: str, + cell_type: Any, + review: dict[str, Any] | None = None, + label: str = "CD8+ T Cell", + **legacy_fields: Any, +) -> dict[str, Any]: + """A ``latest`` entry written by the orchestrator since January 2026.""" + return { + "context": "Cluster context", + "annotation": { + "clusterId": cluster_id, + "annotation": label, + "granularAnnotation": "GZMK+ CD8+ T cell", + "cellState": "Cytotoxic", + "cellOntologyTerm": "CL:0000625", + "cellOntologyTermName": "CD8-positive, alpha-beta T cell", + **EMPTY_LEGACY_FIELDS, + **legacy_fields, + "fullOutput": { + "clusterId": cluster_id, + "cellType": cell_type, + "cellStateWithReasoning": {"label": "Cytotoxic", "reasoning": ""}, + }, + "clusterContext": {}, + }, + "review": review if review is not None else _review("High", False), + "developmentalSimilarity": [], + "functionalSimilarity": [], + } + + +def _legacy_result(cluster_id: str, **annotation_fields: Any) -> dict[str, Any]: + """A ``latest`` entry stored before January 2026: evidence in the legacy fields.""" + return { + "context": "Cluster context", + "annotation": { + "clusterId": cluster_id, + "annotation": "Astrocyte", + "granularAnnotation": "GFAP+ astrocyte", + "cellState": "mature", + "cellOntologyTerm": "CL:0000127", + "cellOntologyTermName": "astrocyte", + **LEGACY_EVIDENCE, + "fullOutput": None, + "clusterContext": None, + **annotation_fields, + }, + "review": {"confidence": "High"}, + "developmentalSimilarity": [], + "functionalSimilarity": [], + } + + +def _results_response() -> dict[str, Any]: + """A ``GET /results`` response mixing every stored result generation.""" + return { + "studyContext": "Human PBMC", + "annotations": { + "1": {"latest": _legacy_result("1"), "history": []}, + "2": { + "latest": _new_result( + "2", _gen2_cell_type(), _review("Moderate", False) + ), + "history": [], + }, + "3": { + "latest": _new_result( + "3", _v2_cell_type(), _review("Low", True, "distinct_populations") + ), + "history": [_legacy_result("3")], + }, + # A cluster without a result, and an older test-fixture shape. + "4": { + "latest": {"developmentalSimilarity": [], "functionalSimilarity": []}, + "history": [], + }, + "5": {"latest": {"annotation": "B cell"}, "history": []}, + }, + "summary": {"tissue": "blood"}, + "semanticOrder": ["1", "2", "3"], + "clusterCategories": [{"categoryName": "Lymphoid", "clusterIds": ["2", "3"]}], + "displayLevel": "", + } + + +def _fetch(monkeypatch: pytest.MonkeyPatch, response: dict[str, Any]) -> dict[str, Any]: + requested: list[str] = [] + + def _get( + self: HTTPTransport, endpoint: str, timeout: int = 30 + ) -> tuple[int, dict[str, Any]]: + requested.append(endpoint) + return 200, response + + monkeypatch.setattr(HTTPTransport, "get", _get) + result = client.fetch_job_results("https://api.test", "token", "job-1") + assert requested == ["results/job-1"] + return result + + +def _old_sdk_result(response: dict[str, Any]) -> dict[str, Any]: + """What SDK versions before the normalization stored for ``response``.""" + annotations = [] + for cluster_id, cluster_data in response["annotations"].items(): + annotation = cluster_data["latest"].get("annotation") + if isinstance(annotation, dict): + annotations.append( + { + "clusterId": annotation.get("clusterId", cluster_id), + "annotation": annotation.get("annotation", "Unknown"), + "ontologyTerm": annotation.get("cellOntologyTermName", "Unknown"), + "ontologyTermID": annotation.get("cellOntologyTerm", "Unknown"), + "granularAnnotation": annotation.get("granularAnnotation", ""), + "cellState": annotation.get("cellState", ""), + **{ + field: annotation.get(field, default) + for field, default in EMPTY_LEGACY_FIELDS.items() + }, + } + ) + return { + "annotations": annotations, + "summary": response["summary"], + "clusterCategories": response["clusterCategories"], + "studyContext": response["studyContext"], + "raw_annotations": response["annotations"], + } + + +def _legacy_values(entry: dict[str, Any]) -> dict[str, Any]: + return {field: entry[field] for field in LEGACY_FIELDS} + + +def _sdk_values(entry: dict[str, Any]) -> dict[str, Any]: + return {key: entry[key] for key in SDK_KEYS} + + +def _by_cluster(result: dict[str, Any]) -> dict[str, dict[str, Any]]: + return {entry["clusterId"]: entry for entry in result["annotations"]} + + +def _normalize(latest: dict[str, Any]) -> dict[str, Any]: + entry = normalize_cluster_annotation("1", {"latest": latest, "history": []}) + assert entry is not None + return entry + + +def test_fetch_job_results_normalizes_every_result_generation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + response = _results_response() + before = copy.deepcopy(response) + + result = _fetch(monkeypatch, response) + annotations = _by_cluster(result) + + # Clusters without an annotation dict are skipped, as before. + assert list(annotations) == ["1", "2", "3"] + assert _legacy_values(annotations["1"]) == LEGACY_EVIDENCE + assert _legacy_values(annotations["2"]) == EXPECTED_FILL + assert _legacy_values(annotations["3"]) == EXPECTED_FILL + assert _sdk_values(annotations["1"]) == { + "reviewerConfidence": "High", + "isHeterogeneous": None, + "heterogeneityType": None, + } + assert _sdk_values(annotations["2"]) == { + "reviewerConfidence": "Moderate", + "isHeterogeneous": False, + "heterogeneityType": None, + } + assert _sdk_values(annotations["3"]) == { + "reviewerConfidence": "Low", + "isHeterogeneous": True, + "heterogeneityType": "distinct_populations", + } + + # Existing keys keep their names, order and values; the new keys come last. + assert list(annotations["3"]) == [*EXISTING_KEYS, *SDK_KEYS] + assert {key: annotations["3"][key] for key in EXISTING_KEYS[:6]} == { + "clusterId": "3", + "annotation": "CD8+ T Cell", + "ontologyTerm": "CD8-positive, alpha-beta T cell", + "ontologyTermID": "CL:0000625", + "granularAnnotation": "GZMK+ CD8+ T cell", + "cellState": "Cytotoxic", + } + + # The raw API data is returned and left as received. + assert result["raw_annotations"] == before["annotations"] + assert response == before + assert result["summary"] == before["summary"] + assert result["clusterCategories"] == before["clusterCategories"] + assert result["studyContext"] == before["studyContext"] + + +def test_non_blank_fields_are_never_overwritten() -> None: + curated: dict[str, Any] = { + "justification": "Curated justification", + "supportingMarkers": ["CD3E"], + "conflictingMarkers": ["CD19"], + "missingExpression": "Curated missing note", + "unexpectedExpression": "Curated unexpected note", + } + + entry = _normalize(_new_result("1", _v2_cell_type(), **curated)) + + assert _legacy_values(entry) == curated + + +def test_only_blank_fields_are_filled() -> None: + entry = _normalize( + _new_result( + "1", + _v2_cell_type(), + justification="Curated justification", + supportingMarkers=None, + conflictingMarkers=["CD19"], + missingExpression=" ", + unexpectedExpression="\n", + ) + ) + + assert _legacy_values(entry) == { + "justification": "Curated justification", + "supportingMarkers": ["CD8A", "CD8B", "GZMK"], + "conflictingMarkers": ["CD19"], + "missingExpression": "`CCR7` and `SELL` mark naive cells.", + "unexpectedExpression": "`MKI67` suggests a proliferating subset.", + } + + +def test_missing_legacy_fields_are_filled_when_a_source_exists() -> None: + latest = _new_result("1", _gen2_cell_type()) + for field in LEGACY_FIELDS: + del latest["annotation"][field] + + assert _legacy_values(_normalize(latest)) == EXPECTED_FILL + + +def test_text_is_copied_as_stored() -> None: + reasoning = " `CCR7` is *absent*.\n" + + entry = _normalize(_new_result("1", _v2_cell_type(missingGenesReasoning=reasoning))) + + assert entry["justification"] == CONCLUSION + assert entry["missingExpression"] == reasoning + + +@pytest.mark.parametrize("reasoning", ["", " \n ", None, 7, ["CCR7"]]) +def test_blank_reasoning_falls_back_to_the_joined_gene_list(reasoning: Any) -> None: + entry = _normalize( + _new_result( + "1", + _gen2_cell_type( + missingGenesReasoning=reasoning, unexpectedGenesReasoning=reasoning + ), + ) + ) + + assert entry["missingExpression"] == "CCR7, SELL" + assert entry["unexpectedExpression"] == "MKI67" + + +def test_gene_lists_keep_only_non_blank_strings() -> None: + noisy = [" CD8A", None, 5, "", " ", {"gene": "CD8B"}, ["GZMK"], True, "GZMK"] + + entry = _normalize( + _new_result( + "1", + _v2_cell_type( + keySupportingGenes=noisy, + unexpectedGenes=noisy, + unexpectedGenesReasoning="", + missingGenes=noisy, + missingGenesReasoning="", + ), + ) + ) + + assert entry["supportingMarkers"] == [" CD8A", "GZMK"] + assert entry["conflictingMarkers"] == [" CD8A", "GZMK"] + assert entry["missingExpression"] == " CD8A, GZMK" + assert entry["unexpectedExpression"] == " CD8A, GZMK" + + +def test_empty_sources_fill_typed_blank_values_and_never_none() -> None: + entry = _normalize( + _new_result( + "1", + _v2_cell_type( + conclusion="", + keySupportingGenes=[None, ""], + unexpectedGenes=[], + unexpectedGenesReasoning="", + missingGenes=[], + missingGenesReasoning="", + ), + justification=None, + supportingMarkers=None, + conflictingMarkers=None, + missingExpression=None, + unexpectedExpression=None, + ) + ) + + assert _legacy_values(entry) == EMPTY_LEGACY_FIELDS + + +def test_missing_or_wrongly_typed_sources_leave_fields_unchanged() -> None: + cell_type = { + "conclusion": ["not", "a", "string"], + "keySupportingGenes": "CD8A, CD8B", + "unexpectedGenes": {"MKI67": 1}, + "missingGenesReasoning": " ", + "unexpectedGenesReasoning": None, + } + + entry = _normalize(_new_result("1", cell_type, justification=None)) + + assert _legacy_values(entry) == {**EMPTY_LEGACY_FIELDS, "justification": None} + + +@pytest.mark.parametrize( + "full_output", + [None, "not a dict", [], {}, {"cellType": "T cell"}, {"cellType": ["T cell"]}], +) +def test_results_without_a_structured_cell_type_keep_their_fields( + full_output: Any, +) -> None: + latest = _legacy_result("1", fullOutput=full_output, justification="") + + entry = _normalize(latest) + + assert _legacy_values(entry) == {**LEGACY_EVIDENCE, "justification": ""} + + +@pytest.mark.parametrize( + ("review", "expected"), + [ + ({"confidence": "high", "isHeterogeneous": "true"}, (None, None, None)), + ({"confidence": ["High"], "isHeterogeneous": 1}, (None, None, None)), + ({"confidence": 0.9, "isHeterogeneous": None}, (None, None, None)), + (_review("Moderate", True, " "), ("Moderate", True, None)), + (_review("Moderate", True, 5), ("Moderate", True, None)), + (_review("High", False, "mixed_lineage"), ("High", False, "mixed_lineage")), + ({"confidence": "Low", "fullOutput": "n/a"}, ("Low", None, None)), + ("not a review", (None, None, None)), + ], +) +def test_reviewer_fields_are_type_checked( + review: Any, expected: tuple[Any, Any, Any] +) -> None: + latest = _new_result("1", _v2_cell_type()) + latest["review"] = review + + assert _sdk_values(_normalize(latest)) == dict(zip(SDK_KEYS, expected)) + + +def test_missing_review_gives_none_values() -> None: + latest = _new_result("1", _v2_cell_type()) + del latest["review"] + + assert _sdk_values(_normalize(latest)) == dict.fromkeys(SDK_KEYS) + + +@pytest.mark.parametrize( + "cluster_data", + [ + {}, + {"latest": {}}, + {"latest": {"developmentalSimilarity": []}}, + {"latest": {"annotation": "T cell", "confidence": 0.95}}, + {"latest": {"annotation": None}}, + {"latest": None}, + "not a cluster", + ], +) +def test_clusters_without_an_annotation_are_skipped(cluster_data: Any) -> None: + assert normalize_cluster_annotation("1", cluster_data) is None + + +@pytest.mark.parametrize( + "latest", + [ + _legacy_result("1"), + _new_result("1", _gen2_cell_type()), + _new_result("1", _v2_cell_type(), _review("Low", True, "doublet_artifact")), + _new_result("1", _v2_cell_type(missingGenesReasoning="", conclusion="")), + ], +) +def test_normalization_is_idempotent_and_does_not_modify_inputs( + latest: dict[str, Any], +) -> None: + cluster_data = {"latest": latest, "history": []} + before = copy.deepcopy(cluster_data) + + entry = normalize_cluster_annotation("1", cluster_data) + assert entry is not None + entry_before = copy.deepcopy(entry) + again = fill_cluster_annotation(entry, cluster_data) + + assert again == entry + assert entry == entry_before + assert cluster_data == before + cell_type = (latest["annotation"]["fullOutput"] or {}).get("cellType", {}) + if "keySupportingGenes" in cell_type: + # Filled lists are copies, so editing the result cannot change the raw data. + assert entry["supportingMarkers"] is not cell_type["keySupportingGenes"] + + +def test_renormalize_results_fills_results_stored_by_older_versions() -> None: + stored = _old_sdk_result(_results_response()) + before = copy.deepcopy(stored) + + result = renormalize_results(stored) + annotations = _by_cluster(result) + + assert stored == before + assert result is not stored + assert _legacy_values(annotations["1"]) == LEGACY_EVIDENCE + assert _legacy_values(annotations["2"]) == EXPECTED_FILL + assert _legacy_values(annotations["3"]) == EXPECTED_FILL + assert _sdk_values(annotations["3"]) == { + "reviewerConfidence": "Low", + "isHeterogeneous": True, + "heterogeneityType": "distinct_populations", + } + assert result["raw_annotations"] == before["raw_annotations"] + assert renormalize_results(result) == result + + +def test_renormalize_results_keeps_values_and_handles_unmatched_entries() -> None: + stored = _old_sdk_result(_results_response()) + stored["annotations"][1]["justification"] = "Kept as stored" + stored["annotations"][1]["reviewerConfidence"] = "High" + stored["annotations"].append({"clusterId": "99", "annotation": "Orphan"}) + stored["annotations"].append("not an entry") + + result = renormalize_results(stored) + + assert result["annotations"][1]["justification"] == "Kept as stored" + assert result["annotations"][1]["reviewerConfidence"] == "High" + assert result["annotations"][1]["supportingMarkers"] == ["CD8A", "CD8B", "GZMK"] + assert result["annotations"][3] == { + "clusterId": "99", + "annotation": "Orphan", + **dict.fromkeys(SDK_KEYS), + } + assert result["annotations"][4] == "not an entry" + + +def test_renormalize_results_without_raw_annotations_adds_empty_review_fields() -> None: + # Results saved by SDK 0.7.0 and older have no raw_annotations. + stored = { + "annotations": [ + {"clusterId": "1", "annotation": "T cell"}, + {"clusterId": "2", "annotation": "B cell", "reviewerConfidence": "High"}, + "not an entry", + ] + } + before = copy.deepcopy(stored) + + result = renormalize_results(stored) + + assert result["annotations"] == [ + {"clusterId": "1", "annotation": "T cell", **dict.fromkeys(SDK_KEYS)}, + { + "clusterId": "2", + "annotation": "B cell", + "reviewerConfidence": "High", + "isHeterogeneous": None, + "heterogeneityType": None, + }, + "not an entry", + ] + assert result is not stored + assert stored == before + + +def test_renormalize_results_pairs_duplicate_cluster_ids_in_order( + monkeypatch: pytest.MonkeyPatch, +) -> None: + response = { + "studyContext": "", + "annotations": { + "a": { + "latest": _new_result( + "X", + _v2_cell_type(keySupportingGenes=["CD8A"]), + _review("High", False), + ), + "history": [], + }, + "no-result": {"latest": {}, "history": []}, + "b": { + "latest": _new_result( + "X", + _v2_cell_type(keySupportingGenes=["MS4A1"]), + _review("Low", True, "mixed_lineage"), + ), + "history": [], + }, + }, + "summary": {}, + "clusterCategories": [], + } + stored = _old_sdk_result(response) + assert [entry["clusterId"] for entry in stored["annotations"]] == ["X", "X"] + + result = renormalize_results(stored) + + assert [entry["supportingMarkers"] for entry in result["annotations"]] == [ + ["CD8A"], + ["MS4A1"], + ] + assert [entry["reviewerConfidence"] for entry in result["annotations"]] == [ + "High", + "Low", + ] + # The same entries fetch_job_results builds from the response. + assert result["annotations"] == _fetch(monkeypatch, response)["annotations"] + + +def test_renormalize_results_falls_back_to_the_raw_cluster_key() -> None: + stored = { + "annotations": [{"clusterId": "x", "annotation": "T cell"}], + "raw_annotations": { + "x": {"latest": {"annotation": "T cell", "review": {"confidence": "Low"}}} + }, + } + + result = renormalize_results(stored) + + assert result["annotations"][0]["reviewerConfidence"] == "Low" + + +def test_load_local_results_renormalizes_h5ad_saved_by_older_versions( + tmp_path: Path, +) -> None: + old_result = _old_sdk_result(_results_response()) + adata = anndata.AnnData(obs=pd.DataFrame(index=["cell1", "cell2"])) + adata.uns["cytetype_results"] = { + "job_id": "old_job", + "result": json.dumps(old_result), + } + path = tmp_path / "old.h5ad" + adata.write_h5ad(path) + adata = anndata.read_h5ad(path) + stored_json = adata.uns["cytetype_results"]["result"] + obs_columns = list(adata.obs.columns) + + result = load_local_results(adata, "cytetype") + + assert result is not None + annotations = _by_cluster(result) + assert _legacy_values(annotations["2"]) == EXPECTED_FILL + assert _sdk_values(annotations["2"]) == { + "reviewerConfidence": "Moderate", + "isHeterogeneous": False, + "heterogeneityType": None, + } + assert result["raw_annotations"] == old_result["raw_annotations"] + assert adata.uns["cytetype_results"]["result"] == stored_json + # Only the returned dict changes; no obs columns are backfilled. + assert list(adata.obs.columns) == obs_columns + + +def test_load_local_results_does_not_mutate_dict_format_results() -> None: + stored = _old_sdk_result(_results_response()) + before = copy.deepcopy(stored) + adata = anndata.AnnData(obs=pd.DataFrame(index=["cell1"])) + adata.uns["cytetype_results"] = {"job_id": "old_job", "result": stored} + + result = load_local_results(adata, "cytetype") + + assert result is not None + assert result is not stored + assert adata.uns["cytetype_results"]["result"] is stored + assert stored == before + assert result["annotations"][1] is not stored["annotations"][1] + assert _legacy_values(result["annotations"][1]) == EXPECTED_FILL + assert result["annotations"][1]["reviewerConfidence"] == "Moderate" + + +def test_store_annotations_adds_reviewer_confidence_column( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + log_messages: list[tuple[str, str]], +) -> None: + result = _fetch(monkeypatch, _results_response()) + clusters = ["1", "1", "2", "2", "3", "4", "5", "3"] + adata = anndata.AnnData(obs=pd.DataFrame(index=[f"cell{i}" for i in range(8)])) + + store_annotations(adata, result, "job-1", "cytetype", "leiden", clusters) + messages = [message for _, message in log_messages] + + column = adata.obs["cytetype_reviewerConfidence_leiden"] + assert isinstance(column.dtype, pd.CategoricalDtype) + assert list(column.cat.categories) == ["High", "Moderate", "Low", "Unknown"] + assert column.tolist() == [ + "High", + "High", + "Moderate", + "Moderate", + "Low", + "Unknown", + "Unknown", + "Low", + ] + # The existing four columns keep their names and values. + assert adata.obs["cytetype_annotation_leiden"].tolist() == [ + "Astrocyte", + "Astrocyte", + "CD8+ T Cell", + "CD8+ T Cell", + "CD8+ T Cell", + "Unknown", + "Unknown", + "CD8+ T Cell", + ] + assert adata.obs["cytetype_cellOntologyTerm_leiden"].tolist()[:3] == [ + "astrocyte", + "astrocyte", + "CD8-positive, alpha-beta T cell", + ] + assert adata.obs["cytetype_cellOntologyTermID_leiden"].tolist()[-3:] == [ + "Unknown", + "Unknown", + "CL:0000625", + ] + assert adata.obs["cytetype_cellState_leiden"].tolist()[4:6] == ["Cytotoxic", ""] + assert sorted(adata.obs.columns) == sorted( + f"cytetype_{field}_leiden" + for field in ( + "annotation", + "cellOntologyTerm", + "cellOntologyTermID", + "cellState", + "reviewerConfidence", + ) + ) + summary = next(m for m in messages if "Full results" in m) + assert "adata.obs['cytetype_cellOntologyTermID_leiden']" in summary + assert "adata.obs['cytetype_reviewerConfidence_leiden']" in summary + assert any("marked as 'Unknown'." in m for m in messages) + + path = tmp_path / "annotated.h5ad" + adata.write_h5ad(path) + reread = anndata.read_h5ad(path).obs["cytetype_reviewerConfidence_leiden"] + assert list(reread.cat.categories) == ["High", "Moderate", "Low", "Unknown"] + + +def test_reviewer_confidence_column_keeps_only_occurring_categories() -> None: + result = { + "annotations": [ + {"clusterId": "1", "annotation": "T cell", "reviewerConfidence": "Low"}, + {"clusterId": "2", "annotation": "B cell", "reviewerConfidence": "bogus"}, + # Results stored without the key (older versions) read as Unknown. + {"clusterId": "3", "annotation": "NK cell"}, + ] + } + adata = anndata.AnnData(obs=pd.DataFrame(index=["a", "b", "c"])) + + store_annotations(adata, result, "job-1", "cytetype", "leiden", ["1", "2", "3"]) + + column = adata.obs["cytetype_reviewerConfidence_leiden"] + assert list(column.cat.categories) == ["Low", "Unknown"] + assert column.tolist() == ["Low", "Unknown", "Unknown"] + + +def _dotplot_response() -> dict[str, Any]: + refined_v2 = _new_result( + "0", + _v2_cell_type(keySupportingGenes=["GENE1", "GENE2", "GENE3", "GENE4"]), + label="Cytotoxic T Cell", + ) + legacy = _legacy_result( + "1", supportingMarkers=["GENE5", "GENE6"], annotation="Astrocyte" + ) + del legacy["annotation"]["fullOutput"] + return { + "studyContext": "", + "annotations": { + # The server can replace annotation.annotation with a refined name. + "0": {"latest": refined_v2, "history": []}, + "1": {"latest": legacy, "history": []}, + "2": {"latest": {"developmentalSimilarity": []}, "history": []}, + }, + "summary": {}, + "clusterCategories": [ + {"categoryName": "Lymphoid", "clusterIds": ["0"]}, + {"categoryName": "Glia", "clusterIds": ["1"]}, + {"categoryName": "Other", "clusterIds": ["2"]}, + ], + } + + +def test_marker_dotplot_handles_legacy_results_and_unknown_clusters( + mock_adata: anndata.AnnData, + monkeypatch: pytest.MonkeyPatch, +) -> None: + import matplotlib + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + result = _fetch(monkeypatch, _dotplot_response()) + clusters = mock_adata.obs["leiden"].astype(str).tolist() + store_annotations(mock_adata, result, "job-1", "cytetype", "leiden", clusters) + + dotplot = marker_dotplot( + mock_adata, + "leiden", + gene_symbols="gene_symbols", + show=False, + return_fig=True, + ) + dotplot.make_figure() + group_labels = [text.get_text() for text in dotplot.ax_dict["gene_group_ax"].texts] + plt.close("all") + + # "Unknown" has no category, so it goes last instead of aborting the plot. + assert list(dotplot.categories_order) == [ + "Cytotoxic T Cell", + "Astrocyte", + "Unknown", + ] + assert list(dotplot.var_names) == ["GENE1", "GENE2", "GENE3", "GENE5", "GENE6"] + # "Other" has no genes, so it gets no bracket or label. + assert list(dotplot.var_groups.labels) == ["Lymphoid", "Glia"] + assert list(dotplot.var_groups.positions) == [(0, 2), (3, 4)] + assert group_labels == ["Lymphoid", "Glia"] + + +def test_marker_dotplot_requires_the_annotation_column( + mock_adata: anndata.AnnData, + monkeypatch: pytest.MonkeyPatch, +) -> None: + result = _fetch(monkeypatch, _dotplot_response()) + mock_adata.uns["cytetype_results"] = { + "job_id": "job-1", + "result": json.dumps(result), + } + + with pytest.raises(KeyError, match="cytetype_annotation_leiden"): + marker_dotplot(mock_adata, "leiden") + + +def _dotplot_adata( + monkeypatch: pytest.MonkeyPatch, + var: pd.DataFrame, + genes: dict[str, list[str]], +) -> anndata.AnnData: + """AnnData annotated with clusters "0" (T cells) and "1" (B cells) and ``genes``.""" + labels = {"0": "T Cell", "1": "B Cell"} + obs = pd.DataFrame( + {"leiden": pd.Categorical(["0", "1"] * 10)}, + index=[f"cell{i}" for i in range(20)], + ) + rng = np.random.default_rng(0) + adata = anndata.AnnData(X=rng.random((20, len(var))), obs=obs, var=var) + response = { + "studyContext": "", + "annotations": { + cluster_id: { + "latest": _new_result( + cluster_id, + _v2_cell_type(keySupportingGenes=cluster_genes), + label=labels[cluster_id], + ), + "history": [], + } + for cluster_id, cluster_genes in genes.items() + }, + "summary": {}, + "clusterCategories": [ + {"categoryName": "T cells", "clusterIds": ["0"]}, + {"categoryName": "B cells", "clusterIds": ["1"]}, + ], + } + result = _fetch(monkeypatch, response) + clusters = adata.obs["leiden"].astype(str).tolist() + store_annotations(adata, result, "job-1", "cytetype", "leiden", clusters) + return adata + + +def test_marker_dotplot_matches_mouse_genes_case_insensitively( + monkeypatch: pytest.MonkeyPatch, +) -> None: + var = pd.DataFrame(index=["Cd8a", "Gzmk", "Ms4a1", "Cd79a"]) + adata = _dotplot_adata( + monkeypatch, var, {"0": ["CD8A", "GZMK"], "1": ["MS4A1", "CD79A"]} + ) + + dotplot = marker_dotplot(adata, "leiden", show=False, return_fig=True) + + assert list(dotplot.var_names) == ["Cd8a", "Gzmk", "Cd79a", "Ms4a1"] + assert list(dotplot.var_groups.labels) == ["T cells", "B cells"] + + +def test_marker_dotplot_matches_the_gene_symbols_column( + monkeypatch: pytest.MonkeyPatch, +) -> None: + var = pd.DataFrame( + {"symbol": ["Cd8a", "Gzmk", "Ms4a1", "Cd79a"]}, + index=[f"ENSMUSG{i:011d}" for i in range(4)], + ) + adata = _dotplot_adata( + monkeypatch, var, {"0": ["CD8A", "GZMK"], "1": ["MS4A1", "CD79A"]} + ) + + dotplot = marker_dotplot( + adata, "leiden", gene_symbols="symbol", show=False, return_fig=True + ) + + assert list(dotplot.var_names) == ["Cd8a", "Gzmk", "Cd79a", "Ms4a1"] + + +def test_marker_dotplot_skips_unmatched_genes_with_one_warning( + monkeypatch: pytest.MonkeyPatch, + log_messages: list[tuple[str, str]], +) -> None: + var = pd.DataFrame(index=["Cd8a", "Gzmk", "Ms4a1", "Cd79a"]) + adata = _dotplot_adata( + monkeypatch, + var, + {"0": ["CD8A", "NOTAGENE"], "1": ["XIST1", "YGENE", "NOTAGENE"]}, + ) + + dotplot = marker_dotplot(adata, "leiden", show=False, return_fig=True) + + assert list(dotplot.var_names) == ["Cd8a"] + # B cells lost all its genes, so the group is dropped. + assert list(dotplot.var_groups.labels) == ["T cells"] + warnings = [message for level, message in log_messages if level == "WARNING"] + assert warnings == [ + "3 marker genes are not shown because they were not found in " + "adata.var_names: NOTAGENE, XIST1, YGENE." + ] + + +def test_marker_dotplot_raises_when_no_marker_gene_is_found( + monkeypatch: pytest.MonkeyPatch, +) -> None: + var = pd.DataFrame(index=["Cd8a", "Gzmk"]) + adata = _dotplot_adata(monkeypatch, var, {"0": ["NOTAGENE"], "1": ["YGENE"]}) + + with pytest.raises(ValueError, match="None of the marker genes were found"): + marker_dotplot(adata, "leiden") diff --git a/uv.lock b/uv.lock index 5a4abd9..0f6bf6c 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,27 @@ resolution-markers = [ "python_full_version < '3.14'", ] +[[package]] +name = "accessible-pygments" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c1/bbac6a50d02774f91572938964c582fff4270eee73ab822a4aeea4d8b11b/accessible_pygments-0.0.5.tar.gz", hash = "sha256:40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872", size = 1377899, upload-time = "2024-05-10T11:23:10.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7", size = 1395903, upload-time = "2024-05-10T11:23:08.421Z" }, +] + +[[package]] +name = "alabaster" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, +] + [[package]] name = "anndata" version = "0.12.10" @@ -498,9 +519,7 @@ dependencies = [ { name = "loguru" }, { name = "natsort" }, { name = "pydantic" }, - { name = "python-dotenv" }, { name = "requests" }, - { name = "session-info" }, ] [package.dev-dependencies] @@ -515,6 +534,21 @@ dev = [ { name = "scanpy" }, { name = "types-requests" }, ] +docs = [ + { name = "ipykernel" }, + { name = "ipywidgets" }, + { name = "jupytext" }, + { name = "myst-parser" }, + { name = "mystmd" }, + { name = "nbclient" }, + { name = "nbformat" }, + { name = "pyyaml" }, + { name = "scanpy", extra = ["leiden"] }, + { name = "sphinx" }, + { name = "sphinx-autodoc-typehints" }, + { name = "sphinx-book-theme" }, + { name = "sphinx-copybutton" }, +] [package.metadata] requires-dist = [ @@ -524,9 +558,7 @@ requires-dist = [ { name = "loguru", specifier = "~=0.7.3" }, { name = "natsort", specifier = "~=8.4.0" }, { name = "pydantic", specifier = "~=2.12.5" }, - { name = "python-dotenv", specifier = "~=1.2.1" }, { name = "requests", specifier = "~=2.32.5" }, - { name = "session-info", specifier = "~=1.0.1" }, ] [package.metadata.requires-dev] @@ -541,6 +573,21 @@ dev = [ { name = "scanpy", specifier = "~=1.12" }, { name = "types-requests", specifier = "~=2.32.4.20260107" }, ] +docs = [ + { name = "ipykernel", specifier = ">=7.2.0" }, + { name = "ipywidgets" }, + { name = "jupytext", specifier = ">=1.19.6" }, + { name = "myst-parser" }, + { name = "mystmd", specifier = ">=1.11.0" }, + { name = "nbclient", specifier = ">=0.10" }, + { name = "nbformat", specifier = ">=5.10" }, + { name = "pyyaml" }, + { name = "scanpy", extras = ["leiden"] }, + { name = "sphinx" }, + { name = "sphinx-autodoc-typehints" }, + { name = "sphinx-book-theme" }, + { name = "sphinx-copybutton" }, +] [[package]] name = "debugpy" @@ -581,6 +628,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, ] +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, +] + [[package]] name = "donfig" version = "0.8.1.post1" @@ -850,6 +906,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/7e/5df541c37bdf6493035e89c22bd53f30d99b291bcda6c78e9a8afeecec2b/igraph-1.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:b607cafc24b10a615e713ee96e58208ef27e0764af80140c7cc45d4724a3f2df", size = 2785701, upload-time = "2025-10-23T12:22:41.03Z" }, ] +[[package]] +name = "imagesize" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/5e/513ff06670c84e7b9887c1fdf61b2d42b4f574a831f2f1d2222023049d8a/imagesize-2.0.1.tar.gz", hash = "sha256:b2ba6a4dea487a7ebcd53248d3476aca449d30db12a2dde5e0c5ca9624fd77e5", size = 1883774, upload-time = "2026-08-24T12:35:19.13Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/f9/575c8d760eae1fc99651b7cc5efd96ad5379ca4d6b53750b0fb4fe983f34/imagesize-2.0.1-py3-none-any.whl", hash = "sha256:ea0c9a0384df69ed86a943a15cde37d0360b82491b3910dc2215e202e62b5b02", size = 14794, upload-time = "2026-08-24T12:35:12.548Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -916,6 +981,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, ] +[[package]] +name = "ipywidgets" +version = "8.1.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "comm" }, + { name = "ipython" }, + { name = "jupyterlab-widgets" }, + { name = "traitlets" }, + { name = "widgetsnbextension" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/7c/6db60eddf38547353b06d57941f5eee22a990640ce30479fd71a810507f2/ipywidgets-8.1.9.tar.gz", hash = "sha256:bcccba38a6ec3253f7a39c943cea5b9ad01999ce071396171adbc51c6a6a8613", size = 117252, upload-time = "2026-08-18T08:54:24.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/55/298e9b3b864a198234997e87a1471c1b17d7f3546ace6d18fb5cf1ce24b2/ipywidgets-8.1.9-py3-none-any.whl", hash = "sha256:f2b8cbcaae10252b809fbe4d7470db75c09b769a32cbf816d20e5ca6d3c5a79d", size = 140101, upload-time = "2026-08-18T08:54:22.339Z" }, +] + [[package]] name = "isoduration" version = "20.11.0" @@ -1172,6 +1253,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/07/a000fe835f76b7e1143242ab1122e6362ef1c03f23f83a045c38859c2ae0/jupyterlab_server-2.28.0-py3-none-any.whl", hash = "sha256:e4355b148fdcf34d312bbbc80f22467d6d20460e8b8736bf235577dd18506968", size = 59830, upload-time = "2025-10-22T13:59:16.767Z" }, ] +[[package]] +name = "jupyterlab-widgets" +version = "3.0.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/8b/e739cf9066ad5037a2d4b0a403f06da374fdccb9748221661c8b492d3dbc/jupyterlab_widgets-3.0.17.tar.gz", hash = "sha256:6e61fe21ca8a66039180a5cc52a433e07279d2fee79c8be963e00d55193f17a8", size = 213919, upload-time = "2026-08-18T08:52:17.511Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/ef/6d27fc118f58cb24886da413545a7efb0853d405fddbfd8b2d9ac09fbed4/jupyterlab_widgets-3.0.17-py3-none-any.whl", hash = "sha256:40ac1e9955acf116c4d995d9bfa082d86ad9ec6d91c4f134827cf5e0a5eb75e0", size = 217292, upload-time = "2026-08-18T08:52:15.47Z" }, +] + +[[package]] +name = "jupytext" +version = "1.19.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "mdit-py-plugins" }, + { name = "nbformat" }, + { name = "packaging" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/7f/5d2cc9618eca61dbc2d76628da3622a311702abd6b15c824bbf2b53e0db6/jupytext-1.19.6.tar.gz", hash = "sha256:ba1d143bb3751e054d3164376b148ee8fa74e1a860fc9411e1da93ff588b8a17", size = 4603767, upload-time = "2026-10-04T21:00:31.103Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/c3/c4eff5bdbe79b688dda8f06f2b66dbcf9a483cf660e5ced0b2dd5c21623d/jupytext-1.19.6-py3-none-any.whl", hash = "sha256:eb725d66841f1a91707bdcf4703b194a53151a060ecb3fd31d78139cc9ec090a", size = 230809, upload-time = "2026-10-04T21:00:29.143Z" }, +] + [[package]] name = "kiwisolver" version = "1.4.9" @@ -1375,6 +1481,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -1504,6 +1622,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, ] +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "mistune" version = "3.2.0" @@ -1555,6 +1694,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "myst-parser" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "jinja2" }, + { name = "markdown-it-py" }, + { name = "mdit-py-plugins" }, + { name = "pyyaml" }, + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/dc/603751677fff302f34396e206b610f556a59d7fe58b9a2145f54e96b48e8/myst_parser-5.1.0.tar.gz", hash = "sha256:ab69322dc6719dcc7f296479dbb70181b66df6ed315064f92dbc85c0e1bf2f02", size = 101182, upload-time = "2026-05-13T09:38:19.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/dc/f3dfb7488b770f3f67e6545085bf2abea5172e88f57b8ad25ef860ca704c/myst_parser-5.1.0-py3-none-any.whl", hash = "sha256:9c91c52b3cdb4d94a6506e4fab4e2f296c7623a0da0dcbe6de1565c3dad67a8a", size = 85817, upload-time = "2026-05-13T09:38:17.904Z" }, +] + +[[package]] +name = "mystmd" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/e0/05c4acc244e317a83cf8da27c2f29b62f86c8ee00d42c3668bf316461b1d/mystmd-1.11.0.tar.gz", hash = "sha256:afc8ca35bcf34cd694b2d5f275dba5baa2e78cf72b4a770369f9a2b6183d7d59", size = 2750170, upload-time = "2026-09-21T16:32:28.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/5e/4566006965545a6461d4b250faafd57a3ff04a193ed664790fbbfe7ba91d/mystmd-1.11.0-py3-none-any.whl", hash = "sha256:52ece5bff807e24e979ca86a806153a3c00247df32321547adc533b8720edab6", size = 2780949, upload-time = "2026-09-21T16:32:26.306Z" }, +] + [[package]] name = "natsort" version = "8.4.0" @@ -1637,6 +1806,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, ] +[[package]] +name = "nodeenv" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/105de02c1322cfada6d9710d9146ef8026419d433c9d08359a2d35805811/nodeenv-1.11.0.tar.gz", hash = "sha256:3ce8fe5b71d16e8af7039ca65257354100bc772965d6bc549070649e53b1b146", size = 99301, upload-time = "2026-09-26T11:29:21.367Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/c8/12811c9b48fde162bb72b6f2e78fada9a247a09d7bb5be2050a5d099c77b/nodeenv-1.11.0-py2.py3-none-any.whl", hash = "sha256:edaa16e6c14d7cf395d75d4bbd5a26390f4dc06501a33b4e76282b02cc688a25", size = 34277, upload-time = "2026-09-26T11:29:19.933Z" }, +] + [[package]] name = "notebook-shim" version = "0.2.4" @@ -2119,6 +2297,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, ] +[[package]] +name = "pydata-sphinx-theme" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "accessible-pygments" }, + { name = "babel" }, + { name = "beautifulsoup4" }, + { name = "docutils" }, + { name = "jinja2" }, + { name = "pygments" }, + { name = "requests" }, + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/8e/add936feaaa9dade7d5b87c6852566d85e518b38ad32224dea32ef958984/pydata_sphinx_theme-0.20.0.tar.gz", hash = "sha256:0da172d41e19a66de875f4002f7054b385372ec65763852193791e658d50bb4a", size = 5004756, upload-time = "2026-07-09T09:09:14.693Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/08/28e2194ed1c3c3a3e86e0600e2dcc7f21dcd670bd31f3efab2e3e2cf0cbd/pydata_sphinx_theme-0.20.0-py3-none-any.whl", hash = "sha256:56744483c9d72c783e075de716ab95d486108b69605df7528078090b73f11f69", size = 6201166, upload-time = "2026-07-09T09:09:12.899Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -2195,15 +2392,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] -[[package]] -name = "python-dotenv" -version = "1.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, -] - [[package]] name = "python-json-logger" version = "4.0.0" @@ -2391,6 +2579,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/71/44ce230e1b7fadd372515a97e32a83011f906ddded8d03e3c6aafbdedbb7/rfc3987_syntax-1.1.0-py3-none-any.whl", hash = "sha256:6c3d97604e4c5ce9f714898e05401a0445a641cfa276432b0a648c80856f6a3f", size = 8046, upload-time = "2025-07-18T01:05:03.843Z" }, ] +[[package]] +name = "roman-numerals" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, +] + [[package]] name = "rpds-py" version = "0.30.0" @@ -2531,6 +2728,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/f0/000ac705a3d5b8744c6eabfce6b413b131829542ffec05020b1e931ffed4/scanpy-1.12-py3-none-any.whl", hash = "sha256:0b89827f9ba9fea8fce5a49b311e9ce34a23f922b7d8506fa845a2dc92ef0bfe", size = 2148747, upload-time = "2026-01-23T13:25:20.84Z" }, ] +[package.optional-dependencies] +leiden = [ + { name = "igraph" }, + { name = "leidenalg" }, +] + [[package]] name = "scikit-learn" version = "1.8.0" @@ -2659,18 +2862,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1c/78/504fdd027da3b84ff1aecd9f6957e65f35134534ccc6da8628eb71e76d3f/send2trash-2.1.0-py3-none-any.whl", hash = "sha256:0da2f112e6d6bb22de6aa6daa7e144831a4febf2a87261451c4ad849fe9a873c", size = 17610, upload-time = "2026-01-14T06:27:35.218Z" }, ] -[[package]] -name = "session-info" -version = "1.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "stdlib-list" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f5/dc/4a0c85aee2034be368d3ca293a563128122dde6db6e1bc9ca9ef3472c731/session_info-1.0.1.tar.gz", hash = "sha256:d71950d5a8ce7f7f7d5e86aa208c148c4e50b5440b77d5544d422b48e4f3ed41", size = 24663, upload-time = "2025-04-11T16:08:43.504Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/c4/f6b7c0ec5241a2bde90c7ba1eca6ba44f8488bcedafe9072c79593015ec0/session_info-1.0.1-py3-none-any.whl", hash = "sha256:451d191e51816070b9f21a6ff3f6eb5d6015ae2738e8db63ac4e6398260a5838", size = 9119, upload-time = "2025-04-11T16:08:42.612Z" }, -] - [[package]] name = "session-info2" version = "0.4" @@ -2698,6 +2889,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "snowballstemmer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/f8/0a71edf031f03c40db17503cb8ca78a69a171254e568e7db241b0ab57ea1/snowballstemmer-3.1.1.tar.gz", hash = "sha256:e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260", size = 123314, upload-time = "2026-06-03T00:56:40.194Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl", hash = "sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752", size = 104164, upload-time = "2026-06-03T00:56:38.614Z" }, +] + [[package]] name = "soupsieve" version = "2.8.3" @@ -2707,6 +2907,126 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, ] +[[package]] +name = "sphinx" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "roman-numerals" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, +] + +[[package]] +name = "sphinx-autodoc-typehints" +version = "3.17.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/fa/c967fd665cc102c90338a90292aba336f852ee6f9b3477ffe4da565edb11/sphinx_autodoc_typehints-3.17.2.tar.gz", hash = "sha256:de41ed4001f4818bb6d37c22fd10ee44a93e6c8e945c6c7e3984e28a9df87d1c", size = 570767, upload-time = "2026-10-17T18:30:30.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/d4/8fc500ff2fafe8a6e1847abd8b1072494dc1a5f136f8e890e973db7de4eb/sphinx_autodoc_typehints-3.17.2-py3-none-any.whl", hash = "sha256:b0ad1f96503d69b63c2a1c043f12e2a727c9549e6503d4adbf2125dbf5ff50ed", size = 140285, upload-time = "2026-10-17T18:30:28.728Z" }, +] + +[[package]] +name = "sphinx-book-theme" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydata-sphinx-theme" }, + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/8a/cb008736682b3974cae6925c54939792fe56f5a167e37984cb45d8a85d8a/sphinx_book_theme-1.4.0.tar.gz", hash = "sha256:fee3cd70573d9a8cd4ab380a3cb1f11139c24dcaa2cf47d06d7f924f1b44eaff", size = 406113, upload-time = "2026-07-19T21:23:54.183Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/5e/6c41ee74f0a4caef53f224c3e6c3869b577dade1a283767bdc874f11e51c/sphinx_book_theme-1.4.0-py3-none-any.whl", hash = "sha256:10fd57d667ee0b565928c3ce95562e281c5d1b94bd5a2aafc5cd41a5d498dcd0", size = 458862, upload-time = "2026-07-19T21:23:52.852Z" }, +] + +[[package]] +name = "sphinx-copybutton" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/2b/a964715e7f5295f77509e59309959f4125122d648f86b4fe7d70ca1d882c/sphinx-copybutton-0.5.2.tar.gz", hash = "sha256:4cf17c82fb9646d1bc9ca92ac280813a3b605d8c421225fd9913154103ee1fbd", size = 23039, upload-time = "2023-04-14T08:10:22.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/48/1ea60e74949eecb12cdd6ac43987f9fd331156388dcc2319b45e2ebb81bf/sphinx_copybutton-0.5.2-py3-none-any.whl", hash = "sha256:fb543fd386d917746c9a2c50360c7905b605726b9355cd26e9974857afeae06e", size = 13343, upload-time = "2023-04-14T08:10:20.844Z" }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + [[package]] name = "stack-data" version = "0.6.3" @@ -2754,15 +3074,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/26/33/f1652d0c59fa51de18492ee2345b65372550501ad061daa38f950be390b6/statsmodels-0.14.6-cp314-cp314-win_amd64.whl", hash = "sha256:151b73e29f01fe619dbce7f66d61a356e9d1fe5e906529b78807df9189c37721", size = 9588010, upload-time = "2025-12-05T23:14:07.28Z" }, ] -[[package]] -name = "stdlib-list" -version = "0.12.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/25/f1540879c8815387980e56f973e54605bd924612399ace31487f7444171c/stdlib_list-0.12.0.tar.gz", hash = "sha256:517824f27ee89e591d8ae7c1dd9ff34f672eae50ee886ea31bb8816d77535675", size = 60923, upload-time = "2025-10-24T19:21:22.849Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/3d/2970b27a11ae17fb2d353e7a179763a2fe6f37d6d2a9f4d40104a2f132e9/stdlib_list-0.12.0-py3-none-any.whl", hash = "sha256:df2d11e97f53812a1756fb5510393a11e3b389ebd9239dc831c7f349957f62f2", size = 87615, upload-time = "2025-10-24T19:21:20.619Z" }, -] - [[package]] name = "terminado" version = "0.18.1" @@ -2861,11 +3172,11 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] [[package]] @@ -2960,6 +3271,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, ] +[[package]] +name = "widgetsnbextension" +version = "4.0.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/60/bc7a980fc78837d6ef8f5940cca4cadc433364503a4c4d42e2a7a0de3231/widgetsnbextension-4.0.16.tar.gz", hash = "sha256:adeea0ae78f0856ee4945f413299801b82a0a01416303301f39a704282a37b73", size = 1111094, upload-time = "2026-08-18T08:52:55.859Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/95/40e17e20046b7bc820d29d09ae84ec157ec8dd6e6f6cd722626292c31b2e/widgetsnbextension-4.0.16-py3-none-any.whl", hash = "sha256:a31a8774885b96fe825462f5d6496166f0c7cae111195b6465c801d230eb5a4e", size = 2225148, upload-time = "2026-08-18T08:52:53.736Z" }, +] + [[package]] name = "win32-setctime" version = "1.2.0"