From 16673c70faeaa1ef0ef401c10325ef7f93295152 Mon Sep 17 00:00:00 2001
From: Trevor Manz <trevor.j.manz@gmail.com>
Date: Thu, 17 Oct 2024 01:42:35 -0400
Subject: [PATCH] Add demo entrypoint

---
 pyproject.toml           |  11 +++-
 src/cev/__init__.py      |   7 +-
 src/cev/_cli.py          | 134 +++++++++++++++++++++++++++++++++++++++
 src/cev/_widget_utils.py |   6 +-
 4 files changed, 145 insertions(+), 13 deletions(-)
 create mode 100644 src/cev/_cli.py

diff --git a/pyproject.toml b/pyproject.toml
index 4e453ec..585a4b9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -19,14 +19,17 @@ classifiers = [
     "Programming Language :: Python :: 3.10",
     "Programming Language :: Python :: 3.11",
 ]
-requires-python = ">=3.8"
+requires-python = ">=3.8,<3.12"
 dependencies = [
     "anywidget>=0.2.3",
     "cev-metrics>=0.1.2",
     "ipywidgets>=8.0.0",
     "jinja2>=3.0.0",
     "jupyter-scatter>=0.14.0",
-    "pandas>=1.0",
+    "pandas>=1.0,<2.0",
+    "numpy>=1.0,<2.0",
+    "pyarrow",
+    "pooch>=1.3.0",
 ]
 dynamic = ["version"]
 
@@ -36,7 +39,6 @@ dev = [
     "black[jupyter]",
     "jupyterlab",
     "pytest",
-    "rich",
     "ruff",
 ]
 notebooks = [
@@ -45,6 +47,9 @@ notebooks = [
     "matplotlib",
 ]
 
+[project.scripts]
+cev = "cev._cli:main"
+
 [project.urls]
 homepage = "https://github.com/OzetteTech/comparative-embedding-visualization"
 
diff --git a/src/cev/__init__.py b/src/cev/__init__.py
index 51da38f..31248d1 100644
--- a/src/cev/__init__.py
+++ b/src/cev/__init__.py
@@ -1,9 +1,4 @@
-from importlib.metadata import PackageNotFoundError, version
+from cev._version import __version__  # noqa
 
 import cev.metrics as metrics  # noqa
 import cev.widgets as widgets  # noqa
-
-try:
-    __version__ = version("cev")
-except PackageNotFoundError:
-    __version__ = "uninstalled"
diff --git a/src/cev/_cli.py b/src/cev/_cli.py
new file mode 100644
index 0000000..d6dde05
--- /dev/null
+++ b/src/cev/_cli.py
@@ -0,0 +1,134 @@
+import argparse
+import json
+import os
+import shutil
+import sys
+import textwrap
+import zipfile
+from pathlib import Path
+
+import pooch
+
+from cev._version import __version__
+
+_DEV = True
+
+
+def download_data() -> tuple[Path, Path]:
+    archive = pooch.retrieve(
+        url="https://figshare.com/ndownloader/articles/23063615/versions/1",
+        path=pooch.os_cache("cev"),
+        fname="data.zip",
+        known_hash=None,
+    )
+    archive = Path(archive)
+    files = [
+        "mair-2022-tissue-138-umap.pq",
+        "mair-2022-tissue-138-ozette.pq",
+    ]
+    with zipfile.ZipFile(archive, "r") as zip_ref:
+        for file in files:
+            zip_ref.extract(file, path=archive.parent)
+    return (
+        archive.parent / "mair-2022-tissue-138-umap.pq",
+        archive.parent / "mair-2022-tissue-138-ozette.pq",
+    )
+
+
+def write_notebook(output: Path):
+    umap_path, ozette_path = download_data()
+    source = textwrap.dedent(
+        f"""
+        import pandas as pd
+        from cev.widgets import Embedding, EmbeddingComparisonWidget
+
+        umap_embedding = pd.read_parquet("{umap_path}").pipe(Embedding.from_ozette)
+        ozette_embedding = pd.read_parquet("{ozette_path}").pipe(Embedding.from_ozette)
+
+        EmbeddingComparisonWidget(
+            umap_embedding,
+            ozette_embedding,
+            titles=("Standard UMAP", "Annotation-Transformed UMAP"),
+            metric="confusion",
+            selection="synced",
+            auto_zoom=True,
+            row_height=320,
+        )
+    """
+    ).strip()
+
+    nb = {
+        "cells": [
+            {
+                "cell_type": "code",
+                "execution_count": None,
+                "metadata": {},
+                "outputs": [],
+                "source": source,
+            }
+        ],
+        "metadata": {
+            "kernelspec": {
+                "display_name": "Python 3",
+                "language": "python",
+                "name": "python3",
+            }
+        },
+        "nbformat": 4,
+        "nbformat_minor": 5,
+    }
+    with output.open("w") as f:
+        json.dump(nb, f, indent=2)
+
+
+def check_uv_available():
+    if shutil.which("uv") is None:
+        print("Error: 'uv' command not found.", file=sys.stderr)
+        print("Please install 'uv' to run `cev demo` entrypoint.", file=sys.stderr)
+        print(
+            "For more information, visit: https://github.com/astral-sh/uv",
+            file=sys.stderr,
+        )
+        sys.exit(1)
+
+
+def run_notebook(notebook_path: Path):
+    check_uv_available()
+    command = [
+        "uvx",
+        "--python",
+        "3.11",
+        "--with",
+        "." if _DEV else f"cev=={__version__}",
+        "--with",
+        "jupyterlab",
+        "jupyter",
+        "lab",
+        str(notebook_path),
+    ]
+    try:
+        os.execvp(command[0], command)
+    except OSError as e:
+        print(f"Error executing {command[0]}: {e}", file=sys.stderr)
+        sys.exit(1)
+
+
+def main():
+    parser = argparse.ArgumentParser(prog="cev")
+    subparsers = parser.add_subparsers(dest="command", help="Available commands")
+    subparsers.add_parser("download", help="Download the demo notebook (and data)")
+    subparsers.add_parser("demo", help="Run the demo notebook in JupyterLab")
+    args = parser.parse_args()
+
+    notebook_path = Path("cev-demo.ipynb")
+    if args.command == "download":
+        write_notebook(notebook_path)
+    elif args.command == "demo":
+        write_notebook(notebook_path)
+        run_notebook(notebook_path)
+    else:
+        parser.print_help()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/src/cev/_widget_utils.py b/src/cev/_widget_utils.py
index 17d3896..e92adb5 100644
--- a/src/cev/_widget_utils.py
+++ b/src/cev/_widget_utils.py
@@ -399,15 +399,13 @@ def robust_labels(labels: npt.ArrayLike, robust: npt.NDArray[np.bool_] | None =
 
 
 @typing.overload
-def create_colormaps(cats: typing.Iterable[str]) -> dict:
-    ...
+def create_colormaps(cats: typing.Iterable[str]) -> dict: ...
 
 
 @typing.overload
 def create_colormaps(
     cats: typing.Iterable[str], *other: typing.Iterable[str]
-) -> tuple[dict, ...]:
-    ...
+) -> tuple[dict, ...]: ...
 
 
 def create_colormaps(