From ae96dde48b72c79ff377d801bfa531959fcdecd6 Mon Sep 17 00:00:00 2001 From: gautam8387 Date: Wed, 5 Aug 2026 21:46:02 +0200 Subject: [PATCH 1/5] auth and api --- .gitignore | 1 + cytetype/__init__.py | 2 +- cytetype/cli.py | 502 +++++++++++++++++++++++++ cytetype/config.py | 88 ++++- cytetype/main.py | 101 +++-- cytetype/templates/cli_callback.html | 180 +++++++++ pyproject.toml | 6 + tests/test_cli.py | 534 +++++++++++++++++++++++++++ tests/test_cytetype_integration.py | 127 ++++++- 9 files changed, 1507 insertions(+), 34 deletions(-) create mode 100644 cytetype/cli.py create mode 100644 cytetype/templates/cli_callback.html create mode 100644 tests/test_cli.py diff --git a/.gitignore b/.gitignore index 8dd1c00..89e2e39 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ wheels/ *ipynb_checkpoints* *query.json notebooks/ +tmp/ \ No newline at end of file diff --git a/cytetype/__init__.py b/cytetype/__init__.py index 2eae0ff..edd4240 100644 --- a/cytetype/__init__.py +++ b/cytetype/__init__.py @@ -1,4 +1,4 @@ -__version__ = "0.19.4" +__version__ = "0.19.5" import requests diff --git a/cytetype/cli.py b/cytetype/cli.py new file mode 100644 index 0000000..d96f5e2 --- /dev/null +++ b/cytetype/cli.py @@ -0,0 +1,502 @@ +import argparse +import base64 +import getpass +import hashlib +import os +import secrets +import shutil +import subprocess +import sys +import time +import webbrowser +from html import escape +from http.server import BaseHTTPRequestHandler, HTTPServer +from importlib.resources import files +from string import Template +from urllib.parse import parse_qs, quote, urlencode, urlparse + +import requests +from pydantic import ValidationError + +from . import __version__ +from .config import ( + DEFAULT_API_URL, + DEFAULT_DASHBOARD_URL, + StoredCredentials, + delete_credentials, + get_default_api_url, + load_credentials, + normalize_api_url, + save_credentials, +) + +_SETUP_TIMEOUT_SECONDS = 300 +_ANSI_RED = "\033[91m" +_ANSI_BLUE = "\033[94m" +_ANSI_RESET = "\033[0m" +_NYGEN_GLYPHS = { + "n": (" ", "# ### ", "## #", "# #", "# #", "# #", "# #"), + "y": ("# #", "# #", "# #", " ####", " #", "# #", " ### "), + "g": (" ### ", "# #", "# #", "# #", " ####", " #", " ### "), + "e": ("#### ", "# #", "#####", "# ", "# ", "# #", " ### "), +} +_NYGEN_WORDMARK_PIXELS = tuple( + " ".join(_NYGEN_GLYPHS[letter][row] for letter in "nygen") for row in range(7) +) +_NYGEN_BANNER = tuple( + "".join("██" if pixel == "#" else " " for pixel in row).rstrip() + for row in _NYGEN_WORDMARK_PIXELS +) +_CALLBACK_PAGE_TEMPLATE = Template( + files("cytetype") + .joinpath("templates", "cli_callback.html") + .read_text(encoding="utf-8") +) + + +def _validate_api_url(value: str) -> str: + api_url = normalize_api_url(value) + parsed = urlparse(api_url) + if ( + parsed.scheme not in {"http", "https"} + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + or parsed.path not in {"", "/"} + ): + raise ValueError("API URL must be an HTTP or HTTPS server origin") + if parsed.scheme == "http" and parsed.hostname not in {"127.0.0.1", "localhost"}: + raise ValueError("Non-local API URLs must use HTTPS") + return api_url + + +def _resolve_dashboard_url(credentials: StoredCredentials) -> str: + api_url = normalize_api_url(credentials.apiUrl) + api_origin = urlparse(api_url) + dashboard_origin = urlparse(credentials.dashboardUrl) + if ( + api_origin.scheme.lower(), + api_origin.netloc.lower(), + ) != ( + dashboard_origin.scheme.lower(), + dashboard_origin.netloc.lower(), + ): + return f"{api_url}/dashboard" + return credentials.dashboardUrl + + +def _render_callback_page( + message: str, + credentials: StoredCredentials | None = None, +) -> bytes: + context = { + "message": escape(message), + "meta_refresh": "", + "state_class": "error", + "icon": "!", + "success_hidden": "hidden", + "error_hidden": "", + "email": "", + "dashboard_url": "", + } + if credentials is not None: + dashboard_url = escape( + _resolve_dashboard_url(credentials), + quote=True, + ) + context.update( + { + "meta_refresh": ( + f'' + ), + "state_class": "success", + "icon": "✓", + "success_hidden": "", + "error_hidden": "hidden", + "email": escape(credentials.email), + "dashboard_url": dashboard_url, + } + ) + + return _CALLBACK_PAGE_TEMPLATE.substitute(context).encode("utf-8") + + +def _create_pkce_pair() -> tuple[str, str]: + verifier = secrets.token_urlsafe(64) + digest = hashlib.sha256(verifier.encode("ascii")).digest() + challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + return verifier, challenge + + +def _open_browser_silently(url: str) -> bool: + """ + Handle the case of WSL. Browser opening is set to default windows browser. + """ + command: list[str] | None = None + is_wsl = sys.platform == "linux" and bool( + os.environ.get("WSL_DISTRO_NAME") or os.environ.get("WSL_INTEROP") + ) + + if is_wsl: + launcher = shutil.which("rundll32.exe") + if launcher is None: + return False + command = [launcher, "url.dll,FileProtocolHandler", url] + elif sys.platform == "linux": + launcher = shutil.which("xdg-open") + if launcher is None: + return False + command = [launcher, url] + elif sys.platform == "darwin": + launcher = shutil.which("open") + if launcher is None: + return False + command = [launcher, url] + + if command is not None: + try: + subprocess.Popen( + command, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + except OSError: + return False + return True + + stderr_fd = 2 + saved_stderr_fd: int | None = None + sink_fd: int | None = None + + try: + saved_stderr_fd = os.dup(stderr_fd) + sink_fd = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink_fd, stderr_fd) + except OSError: + if saved_stderr_fd is not None: + os.close(saved_stderr_fd) + if sink_fd is not None: + os.close(sink_fd) + saved_stderr_fd = None + sink_fd = None + + try: + return bool(webbrowser.open(url)) + except (OSError, webbrowser.Error): + return False + finally: + if saved_stderr_fd is not None: + os.dup2(saved_stderr_fd, stderr_fd) + os.close(saved_stderr_fd) + if sink_fd is not None: + os.close(sink_fd) + + +def _print_setup_banner() -> None: + print() + print("\n".join(_NYGEN_BANNER)) + print() + print(f"Nygen Analytics: {_ANSI_BLUE}https://nygen.io{_ANSI_RESET}") + print() + + +def _run_setup(api_url: str) -> StoredCredentials: + _print_setup_banner() + existing = load_credentials(api_url) + if existing is not None: + print(f"CyteType is already configured for {existing.email}.") + dashboard_url = _resolve_dashboard_url(existing) + print(f"Dashboard: {_ANSI_BLUE}{dashboard_url}{_ANSI_RESET}") + return existing + + state = secrets.token_urlsafe(32) + verifier, challenge = _create_pkce_pair() + result: StoredCredentials | Exception | None = None + redirect_uri = "" + + class CallbackHandler(BaseHTTPRequestHandler): + def log_message(self, format: str, *args: object) -> None: + return + + def respond( + self, + status: int, + message: str, + credentials: StoredCredentials | None = None, + ) -> None: + body = _render_callback_page(message, credentials) + self.send_response(status) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Cache-Control", "no-store") + self.send_header("Referrer-Policy", "no-referrer") + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("X-Frame-Options", "DENY") + self.send_header( + "Content-Security-Policy", + "default-src 'none'; style-src 'unsafe-inline'; " + "base-uri 'none'; form-action 'none'; frame-ancestors 'none'", + ) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self) -> None: + nonlocal result + parsed = urlparse(self.path) + if parsed.path != "/callback": + self.respond(404, "Unknown callback path.") + return + + query = parse_qs(parsed.query) + returned_state = query.get("state", [""])[0] + if not secrets.compare_digest(returned_state, state): + self.respond(400, "Authorization state did not match.") + return + + callback_error = query.get("error", [""])[0] + if callback_error: + result = RuntimeError("CyteType authorization was denied") + self.respond(400, "CyteType authorization failed.") + return + + code = query.get("code", [""])[0] + if not code: + self.respond(400, "Authorization code was missing.") + return + + try: + response = requests.post( + f"{api_url}/auth/cli/token", + json={ + "code": code, + "codeVerifier": verifier, + "redirectUri": redirect_uri, + }, + timeout=30, + ) + if not response.ok: + try: + detail = response.json().get("detail") + except ValueError: + detail = None + raise RuntimeError( + str(detail) if detail else "Token exchange failed" + ) + data = response.json() + if not isinstance(data, dict): + raise TypeError + result = StoredCredentials( + apiUrl=api_url, + dashboardUrl=data.get( + "dashboardUrl", + DEFAULT_DASHBOARD_URL, + ), + apiToken=data["apiToken"], + tokenId=data["tokenId"], + userId=data["userId"], + email=data["email"], + ) + except (KeyError, TypeError, ValidationError, ValueError): + result = RuntimeError("Server returned invalid CLI credentials") + except requests.RequestException: + result = RuntimeError("Could not exchange the authorization code") + except RuntimeError as exchange_error: + result = exchange_error + + if isinstance(result, StoredCredentials): + self.respond( + 200, + "CyteType setup is complete.", + credentials=result, + ) + else: + self.respond(400, "CyteType setup failed.") + + server = HTTPServer(("127.0.0.1", 0), CallbackHandler) + try: + redirect_uri = f"http://127.0.0.1:{server.server_port}/callback" + authorize_url = f"{api_url}/auth/cli/authorize?{ + urlencode( + { + 'redirectUri': redirect_uri, + 'state': state, + 'codeChallenge': challenge, + } + ) + }" + print("Opening CyteType sign-in in your browser.") + print("If it does not open automatically, use this URL:") + print() + print(f"{_ANSI_BLUE}{authorize_url}{_ANSI_RESET}") + print(flush=True) + if not _open_browser_silently(authorize_url): + print(f"{_ANSI_RED}Was not able to launch web browser{_ANSI_RESET}") + + deadline = time.monotonic() + _SETUP_TIMEOUT_SECONDS + while result is None and time.monotonic() < deadline: + server.timeout = min(1.0, max(0.0, deadline - time.monotonic())) + server.handle_request() + finally: + server.server_close() + + if result is None: + raise TimeoutError("CyteType setup timed out") + if isinstance(result, Exception): + raise result + + path = save_credentials(result) + print(f"API key saved for {result.email} in {path}.") + dashboard_url = _resolve_dashboard_url(result) + print(f"Dashboard: {_ANSI_BLUE}{dashboard_url}{_ANSI_RESET}") + return result + + +def _run_login(api_url: str) -> StoredCredentials: + api_token = getpass.getpass("API key: ").strip() + if not api_token: + raise ValueError("API key is required") + + try: + response = requests.get( + f"{api_url}/auth/cli/credentials", + headers={"Authorization": f"Bearer {api_token}"}, + timeout=30, + ) + except requests.RequestException as error: + raise RuntimeError("Could not validate the API key") from error + + if not response.ok: + detail: object | None = None + try: + data = response.json() + if isinstance(data, dict): + detail = data.get("detail") + if isinstance(detail, dict): + detail = detail.get("message") + except ValueError: + pass + raise RuntimeError(str(detail) if detail else "API key validation failed") + + try: + data = response.json() + if not isinstance(data, dict): + raise TypeError + credentials = StoredCredentials( + apiUrl=api_url, + dashboardUrl=data["dashboardUrl"], + apiToken=api_token, + tokenId=data["tokenId"], + userId=data["userId"], + email=data["email"], + ) + except (KeyError, TypeError, ValidationError, ValueError) as error: + raise RuntimeError("Server returned invalid CLI credentials") from error + + path = save_credentials(credentials) + print(f"Signed in as {credentials.email}.") + print(f"API key saved in {path}.") + dashboard_url = _resolve_dashboard_url(credentials) + print(f"Dashboard: {_ANSI_BLUE}{dashboard_url}{_ANSI_RESET}") + return credentials + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="cytetype", + description="Authenticate with CyteType and open your jobs.", + ) + parser.add_argument( + "--version", + action="version", + version=f"%(prog)s {__version__}", + ) + commands = parser.add_subparsers(dest="command") + + setup = commands.add_parser( + "setup", + aliases=["get-key"], + help="Sign in and save a personal API key.", + ) + setup.add_argument( + "--api-url", + default=get_default_api_url(), + help=( + "CyteType server origin. Defaults to CYTETYPE_API_URL or " + f"{DEFAULT_API_URL}." + ), + ) + login = commands.add_parser( + "login", + help="Save and validate an existing API key.", + ) + login.add_argument( + "--api-url", + default=get_default_api_url(), + help=( + "CyteType server origin. Defaults to CYTETYPE_API_URL or " + f"{DEFAULT_API_URL}." + ), + ) + commands.add_parser("dashboard", help="Open the CyteType dashboard.") + view = commands.add_parser("view", help="Open a CyteType job report.") + view.add_argument("job_id", help="Job identifier to open.") + commands.add_parser("logout", help="Remove the locally saved API key.") + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = _build_parser() + args = parser.parse_args(argv) + + try: + if args.command in {"setup", "get-key"}: + _run_setup(_validate_api_url(args.api_url)) + return 0 + + if args.command == "login": + _run_login(_validate_api_url(args.api_url)) + return 0 + + if args.command == "logout": + if delete_credentials(): + print("Local API key removed. Revoke it from the dashboard if needed.") + else: + print("No local CyteType API key was found.") + return 0 + + if args.command == "dashboard": + credentials = load_credentials() + target = ( + _resolve_dashboard_url(credentials) + if credentials + else DEFAULT_DASHBOARD_URL + ) + print(f"{_ANSI_BLUE}{target}{_ANSI_RESET}") + _open_browser_silently(target) + return 0 + + if args.command == "view": + credentials = load_credentials() + api_url = credentials.apiUrl if credentials else DEFAULT_API_URL + redirect = f"/report/{quote(args.job_id, safe='')}" + target = f"{api_url}/login?{urlencode({'redirect': redirect})}" + print(target) + _open_browser_silently(target) + return 0 + + parser.print_help() + return 0 + except (EOFError, OSError, RuntimeError, TimeoutError, ValueError) as error: + print(f"Error: {error}", file=sys.stderr) + return 1 + except KeyboardInterrupt: + print("\nCancelled.", file=sys.stderr) + return 130 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/cytetype/config.py b/cytetype/config.py index 8979525..94569e6 100644 --- a/cytetype/config.py +++ b/cytetype/config.py @@ -1,13 +1,97 @@ -from __future__ import annotations - +import json +import os import sys +import tempfile +from pathlib import Path from typing import TYPE_CHECKING from loguru import logger +from pydantic import BaseModel, Field, ValidationError if TYPE_CHECKING: from loguru import Record +DEFAULT_API_URL = "https://cytetype.nygen.io" +DEFAULT_DASHBOARD_URL = "https://cytetype.nygen.io/dashboard" + + +class StoredCredentials(BaseModel): + apiUrl: str + dashboardUrl: str = DEFAULT_DASHBOARD_URL + apiToken: str = Field(repr=False) + tokenId: str + userId: str + email: str + + +def normalize_api_url(api_url: str) -> str: + return api_url.strip().rstrip("/") + + +def get_default_api_url() -> str: + configured_api_url = os.environ.get("CYTETYPE_API_URL") + if configured_api_url is None: + return DEFAULT_API_URL + return normalize_api_url(configured_api_url) + + +def get_credentials_path() -> Path: + config_home = os.environ.get("XDG_CONFIG_HOME") + if config_home: + return Path(config_home).expanduser() / "cytetype" / "credentials.json" + if os.name == "nt" and os.environ.get("APPDATA"): + return Path(os.environ["APPDATA"]) / "cytetype" / "credentials.json" + return Path.home() / ".config" / "cytetype" / "credentials.json" + + +def save_credentials(credentials: StoredCredentials) -> Path: + path = get_credentials_path() + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp( + dir=path.parent, + prefix=".credentials-", + text=True, + ) + temporary_path = Path(temporary_name) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + descriptor = -1 + json.dump(credentials.model_dump(), handle) + handle.write("\n") + temporary_path.replace(path) + path.chmod(0o600) + except Exception: + if descriptor >= 0: + os.close(descriptor) + temporary_path.unlink(missing_ok=True) + raise + return path + + +def load_credentials(api_url: str | None = None) -> StoredCredentials | None: + path = get_credentials_path() + if not path.exists(): + return None + try: + credentials = StoredCredentials.model_validate_json( + path.read_text(encoding="utf-8") + ) + except (OSError, ValidationError) as error: + raise ValueError(f"Invalid CyteType credentials file: {path}") from error + + if api_url and credentials.apiUrl != normalize_api_url(api_url): + return None + return credentials + + +def delete_credentials() -> bool: + path = get_credentials_path() + if not path.exists(): + return False + path.unlink() + return True + + logger.remove() diff --git a/cytetype/main.py b/cytetype/main.py index e17d549..e4bfdbc 100644 --- a/cytetype/main.py +++ b/cytetype/main.py @@ -1,41 +1,46 @@ import sys +from importlib.metadata import PackageNotFoundError, version from pathlib import Path from typing import Any -from importlib.metadata import PackageNotFoundError, version import anndata import numpy as np from natsort import natsorted -from .config import logger from .api import submit_annotation_job, wait_for_completion from .api.client import ( upload_obs_duckdb as upload_obs_duckdb_file, - upload_vars_h5 as upload_vars_h5_file, -) -from .preprocessing import ( - validate_adata, - resolve_gene_symbols_column, - aggregate_expression_percentages, - extract_marker_genes, - aggregate_cluster_metadata, - extract_visualization_coordinates, ) -from .preprocessing.validation import ( - materialize_canonical_gene_symbols_column, - _generate_unique_na_label, +from .api.client import ( + upload_vars_h5 as upload_vars_h5_file, ) -from .core.payload import build_annotation_payload, save_query_to_file +from .api.exceptions import AuthenticationError +from .config import get_default_api_url, load_credentials, logger, normalize_api_url from .core.artifacts import ( _is_integer_valued, save_features_matrix, +) +from .core.artifacts import ( save_obs_duckdb as save_obs_duckdb_file, ) +from .core.payload import build_annotation_payload, save_query_to_file from .core.results import ( - store_job_details, - store_annotations, - load_local_results, fetch_remote_results, + load_local_results, + store_annotations, + store_job_details, +) +from .preprocessing import ( + aggregate_cluster_metadata, + aggregate_expression_percentages, + extract_marker_genes, + extract_visualization_coordinates, + resolve_gene_symbols_column, + validate_adata, +) +from .preprocessing.validation import ( + _generate_unique_na_label, + materialize_canonical_gene_symbols_column, ) __all__ = ["CyteType"] @@ -71,6 +76,7 @@ class CyteType: marker_genes: dict[str, list[str]] group_metadata: dict[str, dict[str, dict[str, int]]] visualization_data: dict[str, Any] + _auth_token_api_url: str | None __version__: str | None = _get_cytetype_version() def __init__( @@ -88,7 +94,7 @@ def __init__( vars_h5_path: str = "vars.h5", obs_duckdb_path: str = "obs.duckdb", max_metadata_categories: int = 500, - api_url: str = "https://cytetype.nygen.io", + api_url: str | None = None, auth_token: str | None = None, label_na: bool = False, ) -> None: @@ -125,8 +131,8 @@ def __init__( 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, optional): URL for the CyteType API endpoint. Only change if using a custom - deployment. Defaults to "https://cytetype.nygen.io". + 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. label_na (bool, optional): If True, cells with NaN values in the @@ -146,8 +152,11 @@ def __init__( self.pcent_batch_size = pcent_batch_size self.coordinates_key = coordinates_key self.max_cells_per_group = max_cells_per_group - self.api_url = api_url + self.api_url = normalize_api_url( + api_url if api_url is not None else get_default_api_url() + ) self.auth_token = auth_token + self._auth_token_api_url = None self._artifact_build_errors: list[tuple[str, Exception]] = [] self._vars_h5_path: str | None = None self._obs_duckdb_path: str | None = None @@ -445,6 +454,43 @@ def cleanup(self) -> None: self._cleanup_temporary_gene_symbols_column() + def _resolve_auth_token( + self, + api_url: str, + auth_token: str | None = None, + ) -> str: + normalized_api_url = normalize_api_url(api_url) + if auth_token: + self.auth_token = auth_token + self._auth_token_api_url = None + return auth_token + + if self.auth_token and ( + self._auth_token_api_url is None + or 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: + raise AuthenticationError( + f"{error}. Run `cytetype logout`, then `cytetype setup`.", + 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( self, study_context: str, @@ -533,9 +579,8 @@ def run( ) if api_url: - self.api_url = api_url.strip("/") - if auth_token: - self.auth_token = auth_token + self.api_url = normalize_api_url(api_url) + self._resolve_auth_token(self.api_url, auth_token) if upload_timeout_seconds <= 0: raise ValueError("upload_timeout_seconds must be greater than 0") @@ -648,11 +693,13 @@ def get_results( logger.error("Job details found but missing job_id.") return None + job_api_url = normalize_api_url(job_details.get("api_url") or self.api_url) + auth_token = self._resolve_auth_token(job_api_url) return fetch_remote_results( self.adata, job_id, - self.api_url, - self.auth_token, + job_api_url, + auth_token, results_prefix, self.group_key, self.clusters, diff --git a/cytetype/templates/cli_callback.html b/cytetype/templates/cli_callback.html new file mode 100644 index 0000000..991533f --- /dev/null +++ b/cytetype/templates/cli_callback.html @@ -0,0 +1,180 @@ + + + + + + $meta_refresh + $message + + + +
+
CyteType by Nygen Analytics
+ +

$message

+ +
+

Signed in as $email

+

Your API key has been saved on this device.

+ Open dashboard +

+ Redirecting to your dashboard in 5 seconds. +

+ +
+ +

+ Return to the terminal and try again. +

+
+ + diff --git a/pyproject.toml b/pyproject.toml index 4b86c78..b817fa5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,9 @@ Homepage = "https://github.com/NygenAnalytics/cytetype" Issues = "https://github.com/NygenAnalytics/cytetype/issues" Repository = "https://github.com/NygenAnalytics/cytetype" +[project.scripts] +cytetype = "cytetype.cli:main" + [dependency-groups] dev = [ "jupyterlab~=4.5.4", @@ -46,6 +49,9 @@ build-backend = "setuptools.build_meta" [tool.setuptools] packages = {find = {}} +[tool.setuptools.package-data] +cytetype = ["templates/*.html"] + [tool.setuptools.dynamic] version = {attr = "cytetype.__version__"} diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..f98f2e6 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,534 @@ +import os +import stat +import threading +from pathlib import Path +from urllib.error import HTTPError +from urllib.parse import parse_qs, urlparse +from urllib.request import urlopen + +import pytest + +import cytetype.cli as cli +from cytetype.config import ( + DEFAULT_DASHBOARD_URL, + StoredCredentials, + delete_credentials, + get_credentials_path, + load_credentials, + save_credentials, +) + + +@pytest.fixture +def credentials() -> StoredCredentials: + return StoredCredentials( + apiUrl="https://dev.cytetype.example", + dashboardUrl="https://dashboard.cytetype.example/dashboard", + apiToken="cyt_p_secret", + tokenId="token-id", + userId="user-id", + email="researcher@university.edu", + ) + + +def test_credentials_round_trip_is_private_and_server_specific( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + credentials: StoredCredentials, +) -> None: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + + path = save_credentials(credentials) + + assert path == tmp_path / "cytetype" / "credentials.json" + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + assert load_credentials("https://dev.cytetype.example/") == credentials + assert load_credentials("https://other.example") is None + assert delete_credentials() is True + assert delete_credentials() is False + + +def test_invalid_credentials_file_has_clear_error( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + path = get_credentials_path() + path.parent.mkdir(parents=True) + path.write_text('{"apiUrl": "https://dev.example"}', encoding="utf-8") + + with pytest.raises(ValueError, match="Invalid CyteType credentials file"): + load_credentials() + + +def test_existing_credentials_default_to_production_dashboard( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + path = get_credentials_path() + path.parent.mkdir(parents=True) + path.write_text( + ( + '{"apiUrl":"https://dev.cytetype.example",' + '"apiToken":"cyt_p_secret","tokenId":"token-id",' + '"userId":"user-id","email":"researcher@university.edu"}' + ), + encoding="utf-8", + ) + + credentials = load_credentials() + + assert credentials is not None + assert credentials.dashboardUrl == DEFAULT_DASHBOARD_URL + + +def test_help_and_get_key_alias(capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as exit_info: + cli.main(["--help"]) + + assert exit_info.value.code == 0 + output = capsys.readouterr().out + assert "setup" in output + assert "get-key" in output + assert "login" in output + assert "dashboard" in output + assert "view" in output + assert cli._build_parser().parse_args(["get-key"]).command == "get-key" + + +def test_setup_api_url_argument_overrides_environment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv( + "CYTETYPE_API_URL", + "https://dev.cytetype.example/", + ) + parser = cli._build_parser() + + assert parser.parse_args(["setup"]).api_url == "https://dev.cytetype.example" + assert ( + parser.parse_args(["setup", "--api-url", "https://explicit.example"]).api_url + == "https://explicit.example" + ) + assert parser.parse_args(["login"]).api_url == "https://dev.cytetype.example" + + +def test_dashboard_url_uses_api_origin_only_when_origins_differ( + credentials: StoredCredentials, +) -> None: + assert ( + cli._resolve_dashboard_url(credentials) + == "https://dev.cytetype.example/dashboard" + ) + + same_origin = credentials.model_copy( + update={ + "apiUrl": "https://api.cytetype.example", + "dashboardUrl": "https://api.cytetype.example/custom-dashboard", + } + ) + assert ( + cli._resolve_dashboard_url(same_origin) + == "https://api.cytetype.example/custom-dashboard" + ) + + +def test_setup_always_shows_nygen_banner_and_links( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + credentials: StoredCredentials, +) -> None: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + save_credentials(credentials) + + def fail_browser_open(url: str) -> bool: + pytest.fail(f"Existing setup should not open a browser: {url}") + + monkeypatch.setattr(cli, "_open_browser_silently", fail_browser_open) + + result = cli._run_setup(credentials.apiUrl) + + output = capsys.readouterr().out + assert result == credentials + assert cli._NYGEN_GLYPHS["n"] == ( + " ", + "# ### ", + "## #", + "# #", + "# #", + "# #", + "# #", + ) + assert len(cli._NYGEN_BANNER) == 7 + assert all(len(line) <= 72 for line in cli._NYGEN_BANNER) + assert "\n".join(cli._NYGEN_BANNER) in output + assert ( + f"Nygen Analytics: {cli._ANSI_BLUE}https://nygen.io{cli._ANSI_RESET}" + in output + ) + assert ( + "Dashboard: " + f"{cli._ANSI_BLUE}https://dev.cytetype.example/dashboard" + f"{cli._ANSI_RESET}" in output + ) + + +def test_setup_completes_callback_exchange_without_exposing_key( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + opened_urls: list[str] = [] + callback_thread: threading.Thread | None = None + exchange_body: dict[str, str] = {} + callback_page: dict[str, str] = {} + + class FakeResponse: + ok = True + + @staticmethod + def json() -> dict[str, str]: + return { + "apiToken": "cyt_p_returned_secret", + "tokenId": "server-token-id", + "userId": "server-user-id", + "email": "researcher@university.edu", + "dashboardUrl": "https://dashboard.cytetype.example/dashboard", + } + + def fake_post( + url: str, + json: dict[str, str], + timeout: int, + ) -> FakeResponse: + assert url == "https://dev.cytetype.example/auth/cli/token" + assert timeout == 30 + exchange_body.update(json) + return FakeResponse() + + def fake_browser_open(url: str) -> bool: + nonlocal callback_thread + opened_urls.append(url) + query = parse_qs(urlparse(url).query) + callback_url = ( + f"{query['redirectUri'][0]}?code=signed-code&state={query['state'][0]}" + ) + + def call_back() -> None: + with urlopen(callback_url, timeout=5) as response: + assert response.status == 200 + callback_page["body"] = response.read().decode("utf-8") + callback_page["cacheControl"] = response.headers["Cache-Control"] + callback_page["referrerPolicy"] = response.headers["Referrer-Policy"] + callback_page["contentSecurityPolicy"] = response.headers[ + "Content-Security-Policy" + ] + + callback_thread = threading.Thread(target=call_back) + callback_thread.start() + return True + + monkeypatch.setattr(cli.requests, "post", fake_post) + monkeypatch.setattr(cli, "_open_browser_silently", fake_browser_open) + + result = cli._run_setup("https://dev.cytetype.example") + + assert callback_thread is not None + callback_thread.join(timeout=5) + assert not callback_thread.is_alive() + assert result.apiToken == "cyt_p_returned_secret" + assert exchange_body["code"] == "signed-code" + assert exchange_body["redirectUri"].startswith("http://127.0.0.1:") + assert exchange_body["codeVerifier"] not in opened_urls[0] + assert "CyteType setup is complete." in callback_page["body"] + assert "researcher@university.edu" in callback_page["body"] + assert ( + 'content="5;url=https://dev.cytetype.example/dashboard"' + in callback_page["body"] + ) + assert 'href="https://dev.cytetype.example/dashboard"' in callback_page["body"] + assert "cyt_p_returned_secret" not in callback_page["body"] + assert "signed-code" not in callback_page["body"] + assert callback_page["cacheControl"] == "no-store" + assert callback_page["referrerPolicy"] == "no-referrer" + assert "default-src 'none'" in callback_page["contentSecurityPolicy"] + assert load_credentials() == result + output = capsys.readouterr().out + assert "cyt_p_returned_secret" not in output + assert result.dashboardUrl == "https://dashboard.cytetype.example/dashboard" + assert ( + "Dashboard: " + f"{cli._ANSI_BLUE}https://dev.cytetype.example/dashboard" + f"{cli._ANSI_RESET}" in output + ) + assert "If it does not open automatically, use this URL:" in output + assert f"{cli._ANSI_BLUE}https://dev.cytetype.example/auth/cli/authorize?" in output + + +def test_setup_times_out_without_callback( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + monkeypatch.setattr(cli, "_SETUP_TIMEOUT_SECONDS", 0.01) + monkeypatch.setattr(cli, "_open_browser_silently", lambda url: False) + + with pytest.raises(TimeoutError, match="timed out"): + cli._run_setup("https://dev.cytetype.example") + + output = capsys.readouterr().out + assert ( + f"{cli._ANSI_RED}Was not able to launch web browser{cli._ANSI_RESET}" in output + ) + assert "If it does not open automatically, use this URL:" in output + assert f"{cli._ANSI_BLUE}https://dev.cytetype.example/auth/cli/authorize?" in output + + +def test_login_validates_hidden_api_key_before_saving( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + api_token = "cyt_p_existing_secret" + request_data: dict[str, object] = {} + + class FakeResponse: + ok = True + + @staticmethod + def json() -> dict[str, str]: + return { + "tokenId": "existing-token-id", + "userId": "existing-user-id", + "email": "researcher@university.edu", + "dashboardUrl": "https://dashboard.cytetype.example/dashboard", + } + + def fake_get( + url: str, + headers: dict[str, str], + timeout: int, + ) -> FakeResponse: + request_data.update(url=url, headers=headers, timeout=timeout) + return FakeResponse() + + monkeypatch.setattr(cli.getpass, "getpass", lambda prompt: api_token) + monkeypatch.setattr(cli.requests, "get", fake_get) + + credentials = cli._run_login("https://dev.cytetype.example") + + assert request_data == { + "url": "https://dev.cytetype.example/auth/cli/credentials", + "headers": {"Authorization": f"Bearer {api_token}"}, + "timeout": 30, + } + assert load_credentials() == credentials + assert credentials.email == "researcher@university.edu" + output = capsys.readouterr().out + assert api_token not in output + assert "Signed in as researcher@university.edu." in output + assert ( + "Dashboard: " + f"{cli._ANSI_BLUE}https://dev.cytetype.example/dashboard" + f"{cli._ANSI_RESET}" in output + ) + + +def test_login_failure_preserves_existing_credentials( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + credentials: StoredCredentials, +) -> None: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + save_credentials(credentials) + + class FakeResponse: + ok = False + + @staticmethod + def json() -> dict[str, dict[str, str]]: + return {"detail": {"message": "Invalid API token"}} + + def fake_get(*args: object, **kwargs: object) -> FakeResponse: + return FakeResponse() + + monkeypatch.setattr(cli.getpass, "getpass", lambda prompt: "invalid-token") + monkeypatch.setattr(cli.requests, "get", fake_get) + + with pytest.raises(RuntimeError, match="Invalid API token"): + cli._run_login("https://dev.cytetype.example") + + assert load_credentials() == credentials + + +def test_browser_launcher_suppresses_diagnostics( + monkeypatch: pytest.MonkeyPatch, + capfd: pytest.CaptureFixture[str], +) -> None: + monkeypatch.delenv("WSL_DISTRO_NAME", raising=False) + monkeypatch.delenv("WSL_INTEROP", raising=False) + monkeypatch.setattr(cli.sys, "platform", "win32") + + def noisy_browser_open(url: str) -> bool: + os.write(2, f"launcher failed for {url}\n".encode()) + return False + + monkeypatch.setattr(cli.webbrowser, "open", noisy_browser_open) + + assert cli._open_browser_silently("https://dev.cytetype.example") is False + assert capfd.readouterr().err == "" + + +def test_wsl_browser_launcher_is_detached_from_terminal( + monkeypatch: pytest.MonkeyPatch, +) -> None: + launched: list[tuple[list[str], dict[str, object]]] = [] + + def fake_which(command: str) -> str | None: + assert command == "rundll32.exe" + return "/mnt/c/WINDOWS/system32/rundll32.exe" + + def fake_popen(command: list[str], **options: object) -> None: + launched.append((command, options)) + + monkeypatch.setenv("WSL_DISTRO_NAME", "Ubuntu-24.04") + monkeypatch.setattr(cli.shutil, "which", fake_which) + monkeypatch.setattr(cli.subprocess, "Popen", fake_popen) + + url = "https://dev.cytetype.example/auth/cli/authorize?state=test" + assert cli._open_browser_silently(url) is True + assert launched == [ + ( + [ + "/mnt/c/WINDOWS/system32/rundll32.exe", + "url.dll,FileProtocolHandler", + url, + ], + { + "stdin": cli.subprocess.DEVNULL, + "stdout": cli.subprocess.DEVNULL, + "stderr": cli.subprocess.DEVNULL, + "start_new_session": True, + }, + ) + ] + + +def test_wsl_browser_does_not_fall_back_to_terminal_browser( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def fail_browser_open(url: str) -> bool: + pytest.fail(f"WSL must not fall back to a terminal browser: {url}") + + monkeypatch.setenv("WSL_DISTRO_NAME", "Ubuntu-24.04") + monkeypatch.setattr(cli.shutil, "which", lambda command: None) + monkeypatch.setattr(cli.webbrowser, "open", fail_browser_open) + + assert cli._open_browser_silently("https://dev.cytetype.example") is False + + +def test_setup_keyboard_interrupt_exits_without_traceback( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + def interrupt_setup(api_url: str) -> None: + raise KeyboardInterrupt + + monkeypatch.setattr(cli, "_run_setup", interrupt_setup) + + assert cli.main(["setup"]) == 130 + assert capsys.readouterr().err == "\nCancelled.\n" + + +def test_setup_rejects_callback_with_wrong_state( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + monkeypatch.setattr(cli, "_SETUP_TIMEOUT_SECONDS", 0.1) + callback_thread: threading.Thread | None = None + callback_page: dict[str, str] = {} + + def fail_post(*args: object, **kwargs: object) -> None: + pytest.fail("A callback with the wrong state must not exchange a token") + + def fake_browser_open(url: str) -> bool: + nonlocal callback_thread + query = parse_qs(urlparse(url).query) + callback_url = f"{query['redirectUri'][0]}?code=signed-code&state=wrong" + + def call_back() -> None: + with pytest.raises(HTTPError) as error_info: + urlopen(callback_url, timeout=5) + response = error_info.value + assert response.code == 400 + callback_page["body"] = response.read().decode("utf-8") + response.close() + + callback_thread = threading.Thread(target=call_back) + callback_thread.start() + return True + + monkeypatch.setattr(cli.requests, "post", fail_post) + monkeypatch.setattr(cli, "_open_browser_silently", fake_browser_open) + + with pytest.raises(TimeoutError, match="timed out"): + cli._run_setup("https://dev.cytetype.example") + + assert callback_thread is not None + callback_thread.join(timeout=5) + assert not callback_thread.is_alive() + assert "Authorization state did not match." in callback_page["body"] + assert 'class="card error"' in callback_page["body"] + assert 'http-equiv="refresh"' not in callback_page["body"] + + +def test_dashboard_view_and_logout_use_saved_server( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + credentials: StoredCredentials, +) -> None: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + save_credentials(credentials) + opened_urls: list[str] = [] + + def open_url(url: str) -> bool: + opened_urls.append(url) + return True + + monkeypatch.setattr(cli, "_open_browser_silently", open_url) + + assert cli.main(["dashboard"]) == 0 + assert cli.main(["view", "job/with space"]) == 0 + + report_redirect = parse_qs(urlparse(opened_urls[1]).query)["redirect"][0] + assert opened_urls[0] == "https://dev.cytetype.example/dashboard" + assert report_redirect == "/report/job%2Fwith%20space" + assert "cyt_p_secret" not in "".join(opened_urls) + assert ( + f"{cli._ANSI_BLUE}https://dev.cytetype.example/dashboard" + f"{cli._ANSI_RESET}" in capsys.readouterr().out + ) + + assert cli.main(["logout"]) == 0 + assert load_credentials() is None + + +@pytest.mark.parametrize( + "api_url", + [ + "http://example.com", + "https://user@example.com", + "https://example.com/path", + "ftp://example.com", + ], +) +def test_setup_rejects_unsafe_api_url(api_url: str) -> None: + with pytest.raises(ValueError): + cli._validate_api_url(api_url) diff --git a/tests/test_cytetype_integration.py b/tests/test_cytetype_integration.py index cf76493..47cc430 100644 --- a/tests/test_cytetype_integration.py +++ b/tests/test_cytetype_integration.py @@ -1,16 +1,18 @@ """Integration tests for CyteType class.""" -import pytest from pathlib import Path -from unittest.mock import patch, MagicMock -from pydantic import ValidationError from typing import Any +from unittest.mock import MagicMock, patch + import anndata +import pytest import scanpy as sc +from pydantic import ValidationError from cytetype import CyteType -from cytetype.api.exceptions import RateLimitError, AuthenticationError from cytetype.api import UploadResponse +from cytetype.api.exceptions import AuthenticationError, RateLimitError +from cytetype.config import StoredCredentials def test_fixture_works(mock_adata: anndata.AnnData) -> None: @@ -50,6 +52,35 @@ def test_cytetype_initialization(mock_adata: anndata.AnnData) -> None: assert ct.visualization_data["coordinates"] is not None +def test_cytetype_initialization_uses_environment_api_url( + mock_adata: anndata.AnnData, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv( + "CYTETYPE_API_URL", + "https://dev.cytetype.example/", + ) + + ct = CyteType(mock_adata, group_key="leiden") + + assert ct.api_url == "https://dev.cytetype.example" + + +def test_cytetype_api_url_argument_overrides_environment( + mock_adata: anndata.AnnData, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("CYTETYPE_API_URL", "https://environment.example") + + ct = CyteType( + mock_adata, + group_key="leiden", + api_url="https://explicit.example", + ) + + assert ct.api_url == "https://explicit.example" + + @pytest.fixture(autouse=True) def mock_internal_artifact_pipeline(monkeypatch: pytest.MonkeyPatch) -> None: """Avoid file and network work for run() in tests by mocking internals.""" @@ -82,6 +113,16 @@ def _upload_vars(*args: Any, **kwargs: Any) -> UploadResponse: monkeypatch.setattr("cytetype.main.save_obs_duckdb_file", _save_obs) monkeypatch.setattr("cytetype.main.upload_obs_duckdb_file", _upload_obs) monkeypatch.setattr("cytetype.main.upload_vars_h5_file", _upload_vars) + monkeypatch.setattr( + "cytetype.main.load_credentials", + lambda api_url: StoredCredentials( + apiUrl=api_url, + apiToken="stored_test_token", + tokenId="stored-token-id", + userId="stored-user-id", + email="stored@university.edu", + ), + ) def test_cytetype_materializes_canonical_column_from_composite_source( @@ -407,6 +448,9 @@ def test_cytetype_get_results_remote( assert results is not None assert results == mock_api_response mock_fetch.assert_called_once() + fetch_args = mock_fetch.call_args.args + assert fetch_args[2] == "https://api.test" + assert fetch_args[3] == "stored_test_token" def test_cytetype_initialization_with_auth_token(mock_adata: anndata.AnnData) -> None: @@ -522,6 +566,81 @@ def test_cytetype_run_with_auth_token_override( # Verify auth token was updated assert ct.auth_token == "token_override" + assert mock_submit.call_args.args[1] == "token_override" + + +@patch("cytetype.main.wait_for_completion") +@patch("cytetype.main.submit_annotation_job") +def test_cytetype_run_uses_stored_credentials( + mock_submit: MagicMock, + mock_wait: MagicMock, + mock_adata: anndata.AnnData, + mock_api_response: dict[str, Any], +) -> None: + mock_submit.return_value = "job_stored_auth" + mock_wait.return_value = mock_api_response + ct = CyteType(mock_adata, group_key="leiden") + + ct.run(study_context="Test") + + assert ct.auth_token == "stored_test_token" + assert mock_submit.call_args.args[1] == "stored_test_token" + assert mock_wait.call_args.args[1] == "stored_test_token" + + +@patch("cytetype.main.wait_for_completion") +@patch("cytetype.main.submit_annotation_job") +def test_stored_credentials_are_not_reused_for_another_server( + mock_submit: MagicMock, + mock_wait: MagicMock, + mock_adata: anndata.AnnData, + mock_api_response: dict[str, Any], + monkeypatch: pytest.MonkeyPatch, +) -> None: + def load_for_server(api_url: str) -> StoredCredentials | None: + if api_url != "https://cytetype.nygen.io": + return None + return StoredCredentials( + apiUrl=api_url, + apiToken="stored_test_token", + tokenId="stored-token-id", + userId="stored-user-id", + email="stored@university.edu", + ) + + monkeypatch.setattr("cytetype.main.load_credentials", load_for_server) + mock_submit.return_value = "job_stored_auth" + mock_wait.return_value = mock_api_response + ct = CyteType(mock_adata, group_key="leiden") + ct.run(study_context="Test") + + with pytest.raises(AuthenticationError, match="cytetype setup"): + ct.run( + study_context="Test other server", + api_url="https://other.example", + results_prefix="other", + ) + + assert mock_submit.call_count == 1 + + +def test_cytetype_run_requires_setup_before_upload( + mock_adata: anndata.AnnData, + monkeypatch: pytest.MonkeyPatch, +) -> None: + upload_obs = MagicMock() + upload_vars = MagicMock() + monkeypatch.setattr("cytetype.main.load_credentials", lambda api_url: None) + monkeypatch.setattr("cytetype.main.upload_obs_duckdb_file", upload_obs) + monkeypatch.setattr("cytetype.main.upload_vars_h5_file", upload_vars) + ct = CyteType(mock_adata, group_key="leiden") + + with pytest.raises(AuthenticationError, match="cytetype setup") as error_info: + ct.run(study_context="Test") + + assert error_info.value.error_code == "AUTHENTICATION_REQUIRED" + upload_obs.assert_not_called() + upload_vars.assert_not_called() @patch("cytetype.main.submit_annotation_job") From 1a682183f7d756e2ba2698f80ff661f1b5d0e95f Mon Sep 17 00:00:00 2001 From: gautam8387 Date: Wed, 5 Aug 2026 22:25:47 +0200 Subject: [PATCH 2/5] fix tests --- .python-version | 1 + cytetype/cli.py | 19 +++++++++---------- cytetype/config.py | 20 +++++++++++++++++++- tests/test_cli.py | 36 ++++++++++++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 11 deletions(-) create mode 100644 .python-version diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/cytetype/cli.py b/cytetype/cli.py index d96f5e2..2f9fd39 100644 --- a/cytetype/cli.py +++ b/cytetype/cli.py @@ -16,7 +16,7 @@ from urllib.parse import parse_qs, quote, urlencode, urlparse import requests -from pydantic import ValidationError +from pydantic import ValidationError # pyright: ignore[reportMissingImports] from . import __version__ from .config import ( @@ -319,15 +319,14 @@ def do_GET(self) -> None: server = HTTPServer(("127.0.0.1", 0), CallbackHandler) try: redirect_uri = f"http://127.0.0.1:{server.server_port}/callback" - authorize_url = f"{api_url}/auth/cli/authorize?{ - urlencode( - { - 'redirectUri': redirect_uri, - 'state': state, - 'codeChallenge': challenge, - } - ) - }" + query = urlencode( + { + "redirectUri": redirect_uri, + "state": state, + "codeChallenge": challenge, + } + ) + authorize_url = f"{api_url}/auth/cli/authorize?{query}" print("Opening CyteType sign-in in your browser.") print("If it does not open automatically, use this URL:") print() diff --git a/cytetype/config.py b/cytetype/config.py index 94569e6..87ad09f 100644 --- a/cytetype/config.py +++ b/cytetype/config.py @@ -1,5 +1,6 @@ import json import os +import stat import sys import tempfile from pathlib import Path @@ -47,6 +48,23 @@ def get_credentials_path() -> Path: def save_credentials(credentials: StoredCredentials) -> Path: path = get_credentials_path() path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + if os.name == "posix": + directory_stat = path.parent.stat() + if directory_stat.st_uid != os.getuid(): + raise PermissionError( + "CyteType credentials directory is not owned by the current user: " + f"{path.parent}" + ) + if stat.S_IMODE(directory_stat.st_mode) != 0o700: + path.parent.chmod(0o700) + directory_stat = path.parent.stat() + if ( + directory_stat.st_uid != os.getuid() + or stat.S_IMODE(directory_stat.st_mode) != 0o700 + ): + raise PermissionError( + f"Could not secure CyteType credentials directory: {path.parent}" + ) descriptor, temporary_name = tempfile.mkstemp( dir=path.parent, prefix=".credentials-", @@ -95,7 +113,7 @@ def delete_credentials() -> bool: logger.remove() -def _log_format(record: Record) -> str: +def _log_format(record: "Record") -> str: if record["level"].name == "WARNING": return "⚠️ {message}\n" if record["level"].name == "SUCCESS": diff --git a/tests/test_cli.py b/tests/test_cli.py index f98f2e6..5ff4d38 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -41,6 +41,7 @@ def test_credentials_round_trip_is_private_and_server_specific( path = save_credentials(credentials) assert path == tmp_path / "cytetype" / "credentials.json" + assert stat.S_IMODE(path.parent.stat().st_mode) == 0o700 assert stat.S_IMODE(path.stat().st_mode) == 0o600 assert load_credentials("https://dev.cytetype.example/") == credentials assert load_credentials("https://other.example") is None @@ -48,6 +49,41 @@ def test_credentials_round_trip_is_private_and_server_specific( assert delete_credentials() is False +@pytest.mark.skipif(os.name != "posix", reason="POSIX permissions required") +def test_save_credentials_repairs_existing_directory_permissions( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + credentials: StoredCredentials, +) -> None: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + directory = get_credentials_path().parent + directory.mkdir(parents=True) + directory.chmod(0o777) + + path = save_credentials(credentials) + + assert stat.S_IMODE(directory.stat().st_mode) == 0o700 + assert path.exists() + + +@pytest.mark.skipif(os.name != "posix", reason="POSIX ownership required") +def test_save_credentials_rejects_directory_owned_by_another_user( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + credentials: StoredCredentials, +) -> None: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + path = get_credentials_path() + path.parent.mkdir(parents=True) + owner_uid = path.parent.stat().st_uid + monkeypatch.setattr("cytetype.config.os.getuid", lambda: owner_uid + 1) + + with pytest.raises(PermissionError, match="not owned by the current user"): + save_credentials(credentials) + + assert not path.exists() + + def test_invalid_credentials_file_has_clear_error( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, From 46bdda4fa8572da870c6036c4863b1a5336319ef Mon Sep 17 00:00:00 2001 From: gautam8387 Date: Fri, 7 Aug 2026 13:11:50 +0200 Subject: [PATCH 3/5] resolve dash url --- cytetype/cli.py | 51 ++------ cytetype/config.py | 35 ++++++ cytetype/main.py | 24 ++-- tests/test_cli.py | 5 +- tests/test_cytetype_integration.py | 181 ++++++++++++++++++++++++++++- 5 files changed, 236 insertions(+), 60 deletions(-) diff --git a/cytetype/cli.py b/cytetype/cli.py index 2f9fd39..98285dc 100644 --- a/cytetype/cli.py +++ b/cytetype/cli.py @@ -26,8 +26,9 @@ delete_credentials, get_default_api_url, load_credentials, - normalize_api_url, + resolve_dashboard_url, save_credentials, + validate_api_url, ) _SETUP_TIMEOUT_SECONDS = 300 @@ -53,40 +54,6 @@ .read_text(encoding="utf-8") ) - -def _validate_api_url(value: str) -> str: - api_url = normalize_api_url(value) - parsed = urlparse(api_url) - if ( - parsed.scheme not in {"http", "https"} - or not parsed.hostname - or parsed.username is not None - or parsed.password is not None - or parsed.query - or parsed.fragment - or parsed.path not in {"", "/"} - ): - raise ValueError("API URL must be an HTTP or HTTPS server origin") - if parsed.scheme == "http" and parsed.hostname not in {"127.0.0.1", "localhost"}: - raise ValueError("Non-local API URLs must use HTTPS") - return api_url - - -def _resolve_dashboard_url(credentials: StoredCredentials) -> str: - api_url = normalize_api_url(credentials.apiUrl) - api_origin = urlparse(api_url) - dashboard_origin = urlparse(credentials.dashboardUrl) - if ( - api_origin.scheme.lower(), - api_origin.netloc.lower(), - ) != ( - dashboard_origin.scheme.lower(), - dashboard_origin.netloc.lower(), - ): - return f"{api_url}/dashboard" - return credentials.dashboardUrl - - def _render_callback_page( message: str, credentials: StoredCredentials | None = None, @@ -103,7 +70,7 @@ def _render_callback_page( } if credentials is not None: dashboard_url = escape( - _resolve_dashboard_url(credentials), + resolve_dashboard_url(credentials), quote=True, ) context.update( @@ -209,7 +176,7 @@ def _run_setup(api_url: str) -> StoredCredentials: existing = load_credentials(api_url) if existing is not None: print(f"CyteType is already configured for {existing.email}.") - dashboard_url = _resolve_dashboard_url(existing) + dashboard_url = resolve_dashboard_url(existing) print(f"Dashboard: {_ANSI_BLUE}{dashboard_url}{_ANSI_RESET}") return existing @@ -349,7 +316,7 @@ def do_GET(self) -> None: path = save_credentials(result) print(f"API key saved for {result.email} in {path}.") - dashboard_url = _resolve_dashboard_url(result) + dashboard_url = resolve_dashboard_url(result) print(f"Dashboard: {_ANSI_BLUE}{dashboard_url}{_ANSI_RESET}") return result @@ -398,7 +365,7 @@ def _run_login(api_url: str) -> StoredCredentials: path = save_credentials(credentials) print(f"Signed in as {credentials.email}.") print(f"API key saved in {path}.") - dashboard_url = _resolve_dashboard_url(credentials) + dashboard_url = resolve_dashboard_url(credentials) print(f"Dashboard: {_ANSI_BLUE}{dashboard_url}{_ANSI_RESET}") return credentials @@ -453,11 +420,11 @@ def main(argv: list[str] | None = None) -> int: try: if args.command in {"setup", "get-key"}: - _run_setup(_validate_api_url(args.api_url)) + _run_setup(validate_api_url(args.api_url)) return 0 if args.command == "login": - _run_login(_validate_api_url(args.api_url)) + _run_login(validate_api_url(args.api_url)) return 0 if args.command == "logout": @@ -470,7 +437,7 @@ def main(argv: list[str] | None = None) -> int: if args.command == "dashboard": credentials = load_credentials() target = ( - _resolve_dashboard_url(credentials) + resolve_dashboard_url(credentials) if credentials else DEFAULT_DASHBOARD_URL ) diff --git a/cytetype/config.py b/cytetype/config.py index 87ad09f..bc5418f 100644 --- a/cytetype/config.py +++ b/cytetype/config.py @@ -5,6 +5,7 @@ import tempfile from pathlib import Path from typing import TYPE_CHECKING +from urllib.parse import urlparse from loguru import logger from pydantic import BaseModel, Field, ValidationError @@ -29,6 +30,40 @@ def normalize_api_url(api_url: str) -> str: return api_url.strip().rstrip("/") +def validate_api_url(value: str) -> str: + if not isinstance(value, str): + raise ValueError("API URL must be an HTTP or HTTPS server origin") + api_url = normalize_api_url(value) + parsed = urlparse(api_url) + if ( + parsed.scheme not in {"http", "https"} + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + or parsed.path not in {"", "/"} + ): + raise ValueError("API URL must be an HTTP or HTTPS server origin") + if parsed.scheme == "http" and parsed.hostname not in {"127.0.0.1", "localhost"}: + raise ValueError("Non-local API URLs must use HTTPS") + return api_url + + +def resolve_dashboard_url(credentials: StoredCredentials) -> str: + api_url = normalize_api_url(credentials.apiUrl) + api_origin = urlparse(api_url) + dashboard_origin = urlparse(credentials.dashboardUrl) + if ( + api_origin.scheme.lower(), + api_origin.netloc.lower(), + ) != ( + dashboard_origin.scheme.lower(), + dashboard_origin.netloc.lower(), + ): + return f"{api_url}/dashboard" + return credentials.dashboardUrl + def get_default_api_url() -> str: configured_api_url = os.environ.get("CYTETYPE_API_URL") if configured_api_url is None: diff --git a/cytetype/main.py b/cytetype/main.py index e4bfdbc..17ea043 100644 --- a/cytetype/main.py +++ b/cytetype/main.py @@ -15,7 +15,7 @@ upload_vars_h5 as upload_vars_h5_file, ) from .api.exceptions import AuthenticationError -from .config import get_default_api_url, load_credentials, logger, normalize_api_url +from .config import get_default_api_url, load_credentials, logger, validate_api_url from .core.artifacts import ( _is_integer_valued, save_features_matrix, @@ -152,11 +152,11 @@ def __init__( self.pcent_batch_size = pcent_batch_size self.coordinates_key = coordinates_key self.max_cells_per_group = max_cells_per_group - self.api_url = normalize_api_url( + self.api_url = validate_api_url( api_url if api_url is not None else get_default_api_url() ) self.auth_token = auth_token - self._auth_token_api_url = None + self._auth_token_api_url = self.api_url if auth_token else None self._artifact_build_errors: list[tuple[str, Exception]] = [] self._vars_h5_path: str | None = None self._obs_duckdb_path: str | None = None @@ -459,16 +459,13 @@ def _resolve_auth_token( api_url: str, auth_token: str | None = None, ) -> str: - normalized_api_url = normalize_api_url(api_url) + normalized_api_url = validate_api_url(api_url) if auth_token: self.auth_token = auth_token - self._auth_token_api_url = None + self._auth_token_api_url = normalized_api_url return auth_token - if self.auth_token and ( - self._auth_token_api_url is None - or self._auth_token_api_url == normalized_api_url - ): + if self.auth_token and self._auth_token_api_url == normalized_api_url: return self.auth_token self.auth_token = None @@ -578,8 +575,8 @@ def run( f" 3. Use annotator.get_results(results_prefix='{results_prefix}') to retrieve existing results" ) - if api_url: - self.api_url = normalize_api_url(api_url) + if api_url is not None: + self.api_url = validate_api_url(api_url) self._resolve_auth_token(self.api_url, auth_token) if upload_timeout_seconds <= 0: raise ValueError("upload_timeout_seconds must be greater than 0") @@ -693,7 +690,10 @@ def get_results( logger.error("Job details found but missing job_id.") return None - job_api_url = normalize_api_url(job_details.get("api_url") or self.api_url) + stored_api_url = job_details.get("api_url") + job_api_url = validate_api_url( + self.api_url if stored_api_url is None else stored_api_url + ) auth_token = self._resolve_auth_token(job_api_url) return fetch_remote_results( self.adata, diff --git a/tests/test_cli.py b/tests/test_cli.py index 5ff4d38..61c997c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -16,6 +16,7 @@ get_credentials_path, load_credentials, save_credentials, + validate_api_url, ) @@ -565,6 +566,6 @@ def open_url(url: str) -> bool: "ftp://example.com", ], ) -def test_setup_rejects_unsafe_api_url(api_url: str) -> None: +def test_validate_api_url_rejects_unsafe_api_url(api_url: str) -> None: with pytest.raises(ValueError): - cli._validate_api_url(api_url) + validate_api_url(api_url) diff --git a/tests/test_cytetype_integration.py b/tests/test_cytetype_integration.py index 47cc430..34a776c 100644 --- a/tests/test_cytetype_integration.py +++ b/tests/test_cytetype_integration.py @@ -81,6 +81,23 @@ def test_cytetype_api_url_argument_overrides_environment( assert ct.api_url == "https://explicit.example" +@pytest.mark.parametrize( + "api_url", + [ + "http://example.com", + "https://user@example.com", + "https://example.com/path", + "ftp://example.com", + ], +) +def test_cytetype_initialization_rejects_unsafe_api_url( + mock_adata: anndata.AnnData, + api_url: str, +) -> None: + with pytest.raises(ValueError, match="API URL|Non-local"): + CyteType(mock_adata, group_key="leiden", api_url=api_url) + + @pytest.fixture(autouse=True) def mock_internal_artifact_pipeline(monkeypatch: pytest.MonkeyPatch) -> None: """Avoid file and network work for run() in tests by mocking internals.""" @@ -223,6 +240,10 @@ def test_cytetype_run_success( assert "cytetype_results" in result_adata.uns assert "cytetype_jobDetails" in result_adata.uns assert result_adata.uns["cytetype_jobDetails"]["job_id"] == "test_job_123" + assert ( + result_adata.uns["cytetype_jobDetails"]["api_url"] + == "https://cytetype.nygen.io" + ) @patch("cytetype.main.wait_for_completion") @@ -430,16 +451,32 @@ def test_cytetype_get_results_remote( mock_fetch: MagicMock, mock_adata: anndata.AnnData, mock_api_response: dict[str, Any], + monkeypatch: pytest.MonkeyPatch, ) -> None: """Test get_results() fetches from API when not local.""" mock_fetch.return_value = mock_api_response + credential_loader = MagicMock( + return_value=StoredCredentials( + apiUrl="https://api.test", + apiToken="target_test_token", + tokenId="target-token-id", + userId="target-user-id", + email="target@university.edu", + ) + ) + monkeypatch.setattr("cytetype.main.load_credentials", credential_loader) - ct = CyteType(mock_adata, group_key="leiden") + ct = CyteType( + mock_adata, + group_key="leiden", + api_url="https://trusted.test", + auth_token="trusted_test_token", + ) # Store job details (simulating previous run) ct.adata.uns["cytetype_jobDetails"] = { "job_id": "remote_job", - "api_url": "https://api.test", + "api_url": "https://api.test/", } # Retrieve results (should fetch from API) @@ -447,10 +484,110 @@ def test_cytetype_get_results_remote( assert results is not None assert results == mock_api_response + credential_loader.assert_called_once_with("https://api.test") mock_fetch.assert_called_once() fetch_args = mock_fetch.call_args.args assert fetch_args[2] == "https://api.test" - assert fetch_args[3] == "stored_test_token" + assert fetch_args[3] == "target_test_token" + assert ct.auth_token == "target_test_token" + assert ct._auth_token_api_url == "https://api.test" + + +@patch("cytetype.main.fetch_remote_results") +def test_cytetype_get_results_legacy_job_uses_instance_api_url( + mock_fetch: MagicMock, + mock_adata: anndata.AnnData, + mock_api_response: dict[str, Any], + monkeypatch: pytest.MonkeyPatch, +) -> None: + mock_fetch.return_value = mock_api_response + credential_loader = MagicMock() + monkeypatch.setattr("cytetype.main.load_credentials", credential_loader) + ct = CyteType( + mock_adata, + group_key="leiden", + api_url="https://trusted.test/", + auth_token="trusted_test_token", + ) + ct.adata.uns["cytetype_jobDetails"] = {"job_id": "legacy_job"} + + results = ct.get_results() + + assert results == mock_api_response + credential_loader.assert_not_called() + fetch_args = mock_fetch.call_args.args + assert fetch_args[2] == "https://trusted.test" + assert fetch_args[3] == "trusted_test_token" + + +def test_cytetype_get_results_rejects_modified_job_url_before_network( + mock_adata: anndata.AnnData, + monkeypatch: pytest.MonkeyPatch, +) -> None: + credential_loader = MagicMock(return_value=None) + upload_obs = MagicMock() + upload_vars = MagicMock() + submit = MagicMock() + wait = MagicMock() + get_status = MagicMock() + fetch_results = MagicMock() + monkeypatch.setattr("cytetype.main.load_credentials", credential_loader) + 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) + monkeypatch.setattr("cytetype.main.wait_for_completion", wait) + monkeypatch.setattr("cytetype.core.results.get_job_status", get_status) + monkeypatch.setattr("cytetype.core.results.fetch_job_results", fetch_results) + ct = CyteType( + mock_adata, + group_key="leiden", + api_url="https://trusted.test", + auth_token="trusted_test_token", + ) + ct.adata.uns["cytetype_jobDetails"] = { + "job_id": "trusted_job", + "api_url": "https://modified.test", + } + + with pytest.raises(AuthenticationError, match="cytetype setup"): + ct.get_results() + + credential_loader.assert_called_once_with("https://modified.test") + upload_obs.assert_not_called() + upload_vars.assert_not_called() + submit.assert_not_called() + wait.assert_not_called() + get_status.assert_not_called() + fetch_results.assert_not_called() + + +def test_cytetype_get_results_rejects_invalid_job_url_before_credentials( + mock_adata: anndata.AnnData, + monkeypatch: pytest.MonkeyPatch, +) -> None: + credential_loader = MagicMock() + get_status = MagicMock() + fetch_results = MagicMock() + monkeypatch.setattr("cytetype.main.load_credentials", credential_loader) + monkeypatch.setattr("cytetype.core.results.get_job_status", get_status) + monkeypatch.setattr("cytetype.core.results.fetch_job_results", fetch_results) + ct = CyteType( + mock_adata, + group_key="leiden", + api_url="https://trusted.test", + auth_token="trusted_test_token", + ) + ct.adata.uns["cytetype_jobDetails"] = { + "job_id": "trusted_job", + "api_url": "https://modified.test/path", + } + + with pytest.raises(ValueError, match="server origin"): + ct.get_results() + + credential_loader.assert_not_called() + get_status.assert_not_called() + fetch_results.assert_not_called() def test_cytetype_initialization_with_auth_token(mock_adata: anndata.AnnData) -> None: @@ -459,6 +596,7 @@ def test_cytetype_initialization_with_auth_token(mock_adata: anndata.AnnData) -> assert ct.auth_token == "test_token_123" assert ct.api_url == "https://cytetype.nygen.io" + assert ct._auth_token_api_url == "https://cytetype.nygen.io" def test_cytetype_no_coordinates(mock_adata: anndata.AnnData) -> None: @@ -523,6 +661,35 @@ def test_cytetype_run_with_api_url_override( assert ct.api_url == "https://override.api" +def test_cytetype_run_rejects_unsafe_api_url_before_network( + mock_adata: anndata.AnnData, + monkeypatch: pytest.MonkeyPatch, +) -> None: + credential_loader = MagicMock() + upload_obs = MagicMock() + upload_vars = MagicMock() + submit = MagicMock() + wait = MagicMock() + monkeypatch.setattr("cytetype.main.load_credentials", credential_loader) + 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) + monkeypatch.setattr("cytetype.main.wait_for_completion", wait) + ct = CyteType(mock_adata, group_key="leiden") + + with pytest.raises(ValueError, match="server origin"): + ct.run( + study_context="Test", + api_url="https://other.example/path", + ) + + credential_loader.assert_not_called() + upload_obs.assert_not_called() + upload_vars.assert_not_called() + submit.assert_not_called() + wait.assert_not_called() + + def test_cytetype_get_results_no_job_details(mock_adata: anndata.AnnData) -> None: """Test get_results() returns None when no job details exist.""" ct = CyteType(mock_adata, group_key="leiden") @@ -562,10 +729,16 @@ def test_cytetype_run_with_auth_token_override( ct = CyteType(mock_adata, group_key="leiden", auth_token="token_init") # Run with different auth token - ct.run(study_context="Test", auth_token="token_override") + ct.run( + study_context="Test", + api_url="https://override.test/", + auth_token="token_override", + ) # Verify auth token was updated assert ct.auth_token == "token_override" + assert ct.api_url == "https://override.test" + assert ct._auth_token_api_url == "https://override.test" assert mock_submit.call_args.args[1] == "token_override" From 18cec22f3c75c0ddbabc2d6920b9aba2b65d9c44 Mon Sep 17 00:00:00 2001 From: gautam8387 Date: Fri, 7 Aug 2026 13:47:03 +0200 Subject: [PATCH 4/5] added docs --- .gitignore | 1 + README.md | 23 +++++-- cytetype/cli.py | 1 + cytetype/config.py | 1 + docs/cli.md | 144 ++++++++++++++++++++++++++++++++++++++++ docs/configuration.md | 44 ++++++++++-- docs/server-overview.md | 27 ++++---- docs/troubleshooting.md | 18 ++++- tests/test_cli.py | 5 +- 9 files changed, 235 insertions(+), 29 deletions(-) create mode 100644 docs/cli.md diff --git a/.gitignore b/.gitignore index 89e2e39..af49d6e 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ wheels/ .pytest_cache .mypy_cache +.ruff_cache .coverage *.h5ad diff --git a/README.md b/README.md index 4dc74d1..9ed64ea 100644 --- a/README.md +++ b/README.md @@ -42,12 +42,12 @@ CyteType addresses this with a novel agentic architecture: specialized AI agents | Feature | Description | |---------|-------------| | **Cell Ontology Integration** | Automatic CL ID assignment for standardized terminology and cross-study comparison | -| **Confidence Scores** | Numeric certainty values (0–1) for cell type, subtype, and activation state — useful for flagging ambiguous clusters | -| **Linked Literature** | Each annotation includes supporting publications and condition-specific references — see exactly why a call was made | +| **Confidence Scores** | Numeric certainty values (0–1) for cell type, subtype, and activation state, useful for flagging ambiguous clusters | +| **Linked Literature** | Each annotation includes supporting publications and condition-specific references so you can see why a call was made | | **Annotation QC via Match Scores** | Compare CyteType results against your existing annotations to quickly identify discrepancies and validate previous work | | **Embedded Chat Interface** | Explore results interactively; chat is connected to your expression data for on-the-fly queries | -Also included: interactive HTML reports, Scanpy/Seurat compatibility (R wrapper via [CyteTypeR](https://github.com/NygenAnalytics/CyteTypeR)), and no API keys required out of the box. +Also included: interactive HTML reports, Scanpy/Seurat compatibility (R wrapper via [CyteTypeR](https://github.com/NygenAnalytics/CyteTypeR)), and browser-based CLI sign-in with locally saved credentials. 📹 [Watch CyteType intro video](https://vimeo.com/nygen/cytetype) @@ -61,6 +61,14 @@ Also included: interactive HTML reports, Scanpy/Seurat compatibility (R wrapper pip install cytetype ``` +### Sign In + +```bash +cytetype setup +``` + +This opens CyteType sign-in in your browser and saves a personal API key locally. If the browser does not open, follow the URL printed in the terminal. If a saved key is revoked, run `cytetype logout` before running setup again. + ### Basic Usage with Scanpy ```python @@ -70,9 +78,9 @@ from cytetype import CyteType # Assumes preprocessed AnnData with clusters and marker genes group_key = 'clusters' annotator = CyteType( - adata, - group_key=group_key, - rank_key='rank_genes_' + group_key, + adata, + group_key=group_key, + rank_key='rank_genes_' + group_key, n_top_genes=100 ) adata = annotator.run(study_context="Human PBMC from healthy donor") @@ -80,7 +88,7 @@ sc.pl.umap(adata, color='cytetype_annotation_clusters') ``` 🚀 [Try it in Google Colab](https://colab.research.google.com/drive/1aRLsI3mx8JR8u5BKHs48YUbLsqRsh2N7?usp=sharing) -> **Note:** No API keys required for default configuration. See [Configuration](docs/configuration.md) for LLM setup, artifact handling, and advanced options. +CyteType automatically uses the credentials saved by `cytetype setup`. See [CLI and Authentication](docs/cli.md) for existing API keys, custom servers, credential storage, and non-interactive environments. **Using R/Seurat?** → [CyteTypeR](https://github.com/NygenAnalytics/CyteTypeR) @@ -90,6 +98,7 @@ sc.pl.umap(adata, color='cytetype_annotation_clusters') | Resource | Description | |----------|-------------| +| [CLI and Authentication](docs/cli.md) | Sign-in, API keys, saved credentials, and CLI commands | | [Configuration](docs/configuration.md) | LLM settings, parameters, and customization | | [Output Columns](docs/results.md) | Understanding annotation results and metadata | | [Troubleshooting](docs/troubleshooting.md) | Common issues and solutions | diff --git a/cytetype/cli.py b/cytetype/cli.py index 98285dc..aa95bfe 100644 --- a/cytetype/cli.py +++ b/cytetype/cli.py @@ -54,6 +54,7 @@ .read_text(encoding="utf-8") ) + def _render_callback_page( message: str, credentials: StoredCredentials | None = None, diff --git a/cytetype/config.py b/cytetype/config.py index bc5418f..9c9a4ff 100644 --- a/cytetype/config.py +++ b/cytetype/config.py @@ -64,6 +64,7 @@ def resolve_dashboard_url(credentials: StoredCredentials) -> str: return f"{api_url}/dashboard" return credentials.dashboardUrl + def get_default_api_url() -> str: configured_api_url = os.environ.get("CYTETYPE_API_URL") if configured_api_url is None: diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..fa09c50 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,144 @@ +# 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 reports the configured account without opening another browser. + +`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` | Sign in through a browser and save a personal API key | +| `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 index 489bec9..3130de6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -49,19 +49,51 @@ adata = annotator.run( - 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="...", - auth_token="your-auth-token", # included as Authorization: Bearer + 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. +- **`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. @@ -90,7 +122,7 @@ adata = annotator.run( 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`. The job will submit without artifacts — annotation still works, but the interactive report will not have expression lookups or metadata filtering. +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 @@ -111,4 +143,4 @@ adata = annotator.run( timeout_seconds=7200, # Max wait time (default: 2 hours) api_url="https://custom-api.example.com", # Custom API endpoint if needed ) -``` \ No newline at end of file +``` diff --git a/docs/server-overview.md b/docs/server-overview.md index 59f5584..fdc0f22 100644 --- a/docs/server-overview.md +++ b/docs/server-overview.md @@ -8,17 +8,20 @@ CyteType client communicates with a hosted server that performs multi‑agent an - You can re‑annotate a single cluster with feedback without re‑submitting the whole job. ## Key Endpoints -- POST `/annotate` — start a job. Optional `auth_token` for privacy -- GET `/status/{job_id}` — pending/processing/completed/failed + per‑cluster status -- GET `/results/{job_id}` — detailed results (summary + per‑cluster, with latest run) -- GET `/report/{job_id}` — HTML report shell backed by the same `/results` -- POST `/reannotate?job_id=...&cluster_id=...&feedback=...` — single‑cluster retry -- POST `/cluster_chat` — streaming Q&A on a cluster (SSE) - -## Access Control -- If you submit with `auth_token`, the job is private; the same token is required for reads until you publish. -- Submissions without a token are public by default. Your job id is not indexed by search engines, so in effect it is private, unless you share the job id with someone. -- `GET /publish/{job_id}` to make a job public. This can be triggered via report. +- POST `/annotate`: start an authenticated job +- GET `/status/{job_id}`: pending/processing/completed/failed + per‑cluster status +- GET `/results/{job_id}`: detailed results (summary + per‑cluster, with latest run) +- GET `/report/{job_id}`: HTML report shell backed by the same `/results` +- POST `/reannotate?job_id=...&cluster_id=...&feedback=...`: single‑cluster retry +- POST `/cluster_chat`: streaming Q&A on a cluster (SSE) + +## Authentication and Access +- The Python client requires a bearer token before uploading artifacts, submitting jobs, or fetching remote results. +- For local use, run `cytetype setup` and let the client load the saved credentials automatically. +- Saved credentials and direct tokens are tied to the selected API origin. Credentials for one server are not reused for another server. +- `cytetype view ` opens a report through the selected server's browser sign-in flow without putting the API key in the URL. + +See [CLI and Authentication](./cli.md) for setup and credential handling. ## Rate Limits (typical defaults) - Annotate: 5/day (Unlimited when a LLM is provided) @@ -31,4 +34,4 @@ CyteType client communicates with a hosted server that performs multi‑agent an - Results include annotations, ontology terms, evidence, literature, and usage summaries. - History is preserved per cluster run; the server returns the latest by timestamp. -For more detail on how the multi‑agent system operates, see your deployment’s documentation or contact support. \ No newline at end of file +For more detail on how the multi‑agent system operates, see your deployment’s documentation or contact support. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index e4394da..9ba2598 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,9 +1,23 @@ # 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 the credentials file is invalid or unreadable, run `cytetype logout` followed by `cytetype 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. +- 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. \ No newline at end of file +- 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/tests/test_cli.py b/tests/test_cli.py index 61c997c..65d5c03 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -15,6 +15,7 @@ delete_credentials, get_credentials_path, load_credentials, + resolve_dashboard_url, save_credentials, validate_api_url, ) @@ -155,7 +156,7 @@ def test_dashboard_url_uses_api_origin_only_when_origins_differ( credentials: StoredCredentials, ) -> None: assert ( - cli._resolve_dashboard_url(credentials) + resolve_dashboard_url(credentials) == "https://dev.cytetype.example/dashboard" ) @@ -166,7 +167,7 @@ def test_dashboard_url_uses_api_origin_only_when_origins_differ( } ) assert ( - cli._resolve_dashboard_url(same_origin) + resolve_dashboard_url(same_origin) == "https://api.cytetype.example/custom-dashboard" ) From 953a65e85a8a26ea1361851bab5732b67dd5e208 Mon Sep 17 00:00:00 2001 From: gautam8387 Date: Mon, 10 Aug 2026 19:03:50 +0200 Subject: [PATCH 5/5] cli setup check --- README.md | 122 ++++++++++++++------------------------ cytetype/cli.py | 57 +++++++++++++++++- docs/cli.md | 4 +- docs/troubleshooting.md | 3 +- tests/test_cli.py | 127 +++++++++++++++++++++++++++++++++++++++- 5 files changed, 225 insertions(+), 88 deletions(-) diff --git a/README.md b/README.md index 9ed64ea..af3a3ae 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ +

CyteType

Agentic, Evidence-Based Cell Type Annotation for Single-Cell RNA-seq

@@ -17,127 +18,94 @@

-**CyteType** performs **automated cell type annotation** in **single-cell RNA sequencing (scRNA-seq)** data. It uses a multi-agent AI architecture to deliver transparent, evidence-based annotations with Cell Ontology mapping. - -Integrates with **Scanpy** and **Seurat** workflows. - ---- - -> **Preprint published:** Nov. 7, 2025: [bioRxiv link](https://www.biorxiv.org/content/10.1101/2025.11.06.686964v1) - Dive into benchmarking results - ---- - -## Why CyteType? - -Cell type annotation is one of the most time-consuming steps in single-cell analysis. It typically requires weeks of expert curation, and the results often vary between annotators. When annotations do get done, the reasoning is rarely documented; this makes it difficult to reproduce or audit later. - -CyteType addresses this with a novel agentic architecture: specialized AI agents collaborate on marker gene analysis, literature evidence retrieval, and ontology mapping. The result is consistent, reproducible annotations with a full evidence trail for every decision. - -CyteType multi-agent AI architecture for single-cell RNA-seq cell type annotation - ---- - -## Key Features - -| Feature | Description | -|---------|-------------| -| **Cell Ontology Integration** | Automatic CL ID assignment for standardized terminology and cross-study comparison | -| **Confidence Scores** | Numeric certainty values (0–1) for cell type, subtype, and activation state, useful for flagging ambiguous clusters | -| **Linked Literature** | Each annotation includes supporting publications and condition-specific references so you can see why a call was made | -| **Annotation QC via Match Scores** | Compare CyteType results against your existing annotations to quickly identify discrepancies and validate previous work | -| **Embedded Chat Interface** | Explore results interactively; chat is connected to your expression data for on-the-fly queries | - -Also included: interactive HTML reports, Scanpy/Seurat compatibility (R wrapper via [CyteTypeR](https://github.com/NygenAnalytics/CyteTypeR)), and browser-based CLI sign-in with locally saved credentials. +**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. -📹 [Watch CyteType intro video](https://vimeo.com/nygen/cytetype) +For Seurat workflows, use [CyteTypeR](https://github.com/NygenAnalytics/CyteTypeR). ---- +> [!IMPORTANT] +> CyteType requires an API key. Use is free for academic and non-commercial research. Commercial use requires a [license](#license). ## Quick Start -### Installation +### 1. Install ```bash pip install cytetype ``` -### Sign In +### 2. Set up your API key ```bash cytetype setup ``` -This opens CyteType sign-in in your browser and saves a personal API key locally. If the browser does not open, follow the URL printed in the terminal. If a saved key is revoked, run `cytetype logout` before running setup again. +This opens passwordless CyteType sign-in in your browser and saves the API key locally for automatic use from Python. You can also [create or manage API keys in the dashboard](https://cytetype.nygen.io/dashboard). -### Basic Usage with Scanpy +Already have an API key? Save and validate it locally once: + +```bash +cytetype login +``` + +### 3. Annotate with Scanpy ```python import scanpy as sc from cytetype import CyteType # Assumes preprocessed AnnData with clusters and marker genes -group_key = 'clusters' +group_key = "clusters" annotator = CyteType( adata, group_key=group_key, - rank_key='rank_genes_' + group_key, - n_top_genes=100 + rank_key=f"rank_genes_{group_key}", + n_top_genes=100, ) -adata = annotator.run(study_context="Human PBMC from healthy donor") -sc.pl.umap(adata, color='cytetype_annotation_clusters') +adata = annotator.run(study_context="Human PBMC from a healthy donor") +sc.pl.umap(adata, color="cytetype_annotation_clusters") ``` -🚀 [Try it in Google Colab](https://colab.research.google.com/drive/1aRLsI3mx8JR8u5BKHs48YUbLsqRsh2N7?usp=sharing) - -CyteType automatically uses the credentials saved by `cytetype setup`. See [CLI and Authentication](docs/cli.md) for existing API keys, custom servers, credential storage, and non-interactive environments. - -**Using R/Seurat?** → [CyteTypeR](https://github.com/NygenAnalytics/CyteTypeR) ---- +[Try CyteType in Google Colab](https://colab.research.google.com/drive/1aRLsI3mx8JR8u5BKHs48YUbLsqRsh2N7?usp=sharing). -## Documentation +## What You Get -| Resource | Description | -|----------|-------------| -| [CLI and Authentication](docs/cli.md) | Sign-in, API keys, saved credentials, and CLI commands | -| [Configuration](docs/configuration.md) | LLM settings, parameters, and customization | -| [Output Columns](docs/results.md) | Understanding annotation results and metadata | -| [Troubleshooting](docs/troubleshooting.md) | Common issues and solutions | -| [Development](docs/development.md) | Contributing and local setup | -| [Discord](https://discord.gg/V6QFM4AN) | Community support | +- **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 ---- +## Example Report -## Output Reports - -Each analysis generates an HTML report documenting annotation decisions, reviewer comments and an embedded chat interface for further exploration. +Each analysis generates an HTML report with annotation decisions, reviewer comments, supporting evidence, and an embedded chat interface connected to your expression data. CyteType HTML report showing cell type annotations marker genes - [View example report](https://cytetype.nygen.io/report/e70e2883-7713-4121-94f2-5b57eabd1468?v=260303) ---- - ## Benchmarks -Validated across PBMC, bone marrow, tumor microenvironment, and cross-species datasets. CyteType's agentic architecture consistently outperforms existing annotation methods: +Across PBMC, bone marrow, tumor microenvironment, and cross-species datasets, the multi-agent approach outperforms existing annotation methods: -| Comparison | Improvement | -|------------|-------------| -| vs GPTCellType | +388% | -| vs CellTypist | +268% | -| vs SingleR | +101% | +| Compared with | Improvement | +|---------------|-------------| +| GPTCellType | +388% | +| CellTypist | +268% | +| SingleR | +101% | -CyteType benchmark comparison against GPTCellType CellTypist SingleR +Methods and full results are in the [preprint](https://www.biorxiv.org/content/10.1101/2025.11.06.686964v1). You can also [browse results on atlas-scale datasets](docs/examples.md). -[Browse CyteType results on atlas scale datasets](docs/examples.md) +## 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. +- 🧑‍💻 [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. ## Citation -If you use CyteType in your research, please cite our 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://www.biorxiv.org/content/10.1101/2025.11.06.686964v1) ```bibtex @@ -151,12 +119,8 @@ If you use CyteType in your research, please cite our preprint: } ``` ---- - ## License CyteType is free for academic and non-commercial research under [CC BY-NC-SA 4.0](LICENSE.md). For commercial licensing, contact [contact@nygen.io](mailto:contact@nygen.io). - ---- diff --git a/cytetype/cli.py b/cytetype/cli.py index aa95bfe..489d269 100644 --- a/cytetype/cli.py +++ b/cytetype/cli.py @@ -172,10 +172,56 @@ def _print_setup_banner() -> None: print() -def _run_setup(api_url: str) -> StoredCredentials: +def _run_setup(api_url: str, force: bool = False) -> StoredCredentials: _print_setup_banner() - existing = load_credentials(api_url) + existing = None if force else load_credentials(api_url) if existing is not None: + try: + response = requests.get( + f"{api_url}/auth/cli/credentials", + headers={"Authorization": f"Bearer {existing.apiToken}"}, + timeout=30, + ) + except requests.RequestException as error: + raise RuntimeError("Could not validate the saved API key") from error + + if not response.ok: + detail: object | None = None + error_code: str | None = None + try: + data = response.json() + if isinstance(data, dict): + detail = data.get("detail") + if isinstance(detail, dict): + response_error_code = detail.get("error_code") + if isinstance(response_error_code, str): + error_code = response_error_code + detail = detail.get("message") + except ValueError: + pass + message = str(detail) if detail else "Saved API key validation failed" + if error_code in {"INVALID_TOKEN", "TOKEN_INACTIVE"}: + message += ( + ". Run `cytetype setup --force` to re-authenticate with a new key" + ) + raise RuntimeError(message) + + try: + data = response.json() + if not isinstance(data, dict): + raise TypeError + existing = StoredCredentials( + apiUrl=api_url, + dashboardUrl=data["dashboardUrl"], + apiToken=existing.apiToken, + tokenId=data["tokenId"], + userId=data["userId"], + email=data["email"], + ) + except (KeyError, TypeError, ValidationError, ValueError) as error: + raise RuntimeError("Server returned invalid CLI credentials") from error + + save_credentials(existing) print(f"CyteType is already configured for {existing.email}.") dashboard_url = resolve_dashboard_url(existing) print(f"Dashboard: {_ANSI_BLUE}{dashboard_url}{_ANSI_RESET}") @@ -396,6 +442,11 @@ def _build_parser() -> argparse.ArgumentParser: f"{DEFAULT_API_URL}." ), ) + setup.add_argument( + "--force", + action="store_true", + help="Re-authenticate without validating saved credentials.", + ) login = commands.add_parser( "login", help="Save and validate an existing API key.", @@ -421,7 +472,7 @@ def main(argv: list[str] | None = None) -> int: try: if args.command in {"setup", "get-key"}: - _run_setup(validate_api_url(args.api_url)) + _run_setup(validate_api_url(args.api_url), force=args.force) return 0 if args.command == "login": diff --git a/docs/cli.md b/docs/cli.md index fa09c50..08036f0 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -19,7 +19,7 @@ The command: 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 reports the configured account without opening another browser. +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`. @@ -55,7 +55,7 @@ The key is entered through a hidden prompt. CyteType validates it with the selec | Command | Purpose | | --- | --- | -| `cytetype setup` | Sign in through a browser and save a personal API key | +| `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 | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 9ba2598..564cbff 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -7,7 +7,8 @@ - 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 the credentials file is invalid or unreadable, run `cytetype logout` followed by `cytetype setup`. +- 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. diff --git a/tests/test_cli.py b/tests/test_cli.py index 65d5c03..5da0f6b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,6 +2,7 @@ import stat import threading from pathlib import Path +from types import SimpleNamespace from urllib.error import HTTPError from urllib.parse import parse_qs, urlparse from urllib.request import urlopen @@ -133,6 +134,8 @@ def test_help_and_get_key_alias(capsys: pytest.CaptureFixture[str]) -> None: assert "dashboard" in output assert "view" in output assert cli._build_parser().parse_args(["get-key"]).command == "get-key" + assert cli._build_parser().parse_args(["setup"]).force is False + assert cli._build_parser().parse_args(["get-key", "--force"]).force is True def test_setup_api_url_argument_overrides_environment( @@ -152,6 +155,32 @@ def test_setup_api_url_argument_overrides_environment( assert parser.parse_args(["login"]).api_url == "https://dev.cytetype.example" +def test_get_key_force_reaches_setup( + monkeypatch: pytest.MonkeyPatch, + credentials: StoredCredentials, +) -> None: + calls: list[tuple[str, bool]] = [] + + def fake_setup(api_url: str, force: bool = False) -> StoredCredentials: + calls.append((api_url, force)) + return credentials + + monkeypatch.setattr(cli, "_run_setup", fake_setup) + + assert ( + cli.main( + [ + "get-key", + "--api-url", + "https://dev.cytetype.example", + "--force", + ] + ) + == 0 + ) + assert calls == [("https://dev.cytetype.example", True)] + + def test_dashboard_url_uses_api_origin_only_when_origins_differ( credentials: StoredCredentials, ) -> None: @@ -181,15 +210,40 @@ def test_setup_always_shows_nygen_banner_and_links( monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) save_credentials(credentials) + response = SimpleNamespace( + ok=True, + json=lambda: { + "tokenId": "refreshed-token-id", + "userId": "refreshed-user-id", + "email": "current@university.edu", + "dashboardUrl": "https://dev.cytetype.example/dashboard", + }, + ) + + def fake_get( + url: str, + headers: dict[str, str], + timeout: int, + ) -> SimpleNamespace: + assert url == "https://dev.cytetype.example/auth/cli/credentials" + assert headers == {"Authorization": f"Bearer {credentials.apiToken}"} + assert timeout == 30 + return response + def fail_browser_open(url: str) -> bool: pytest.fail(f"Existing setup should not open a browser: {url}") + monkeypatch.setattr(cli.requests, "get", fake_get) monkeypatch.setattr(cli, "_open_browser_silently", fail_browser_open) result = cli._run_setup(credentials.apiUrl) output = capsys.readouterr().out - assert result == credentials + assert result.apiToken == credentials.apiToken + assert result.tokenId == "refreshed-token-id" + assert result.userId == "refreshed-user-id" + assert result.email == "current@university.edu" + assert load_credentials() == result assert cli._NYGEN_GLYPHS["n"] == ( " ", "# ### ", @@ -211,14 +265,74 @@ def fail_browser_open(url: str) -> bool: f"{cli._ANSI_BLUE}https://dev.cytetype.example/dashboard" f"{cli._ANSI_RESET}" in output ) + assert credentials.apiToken not in output + + +@pytest.mark.parametrize("error_code", ["INVALID_TOKEN", "TOKEN_INACTIVE"]) +def test_setup_rejects_invalid_saved_credentials_without_changing_them( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + credentials: StoredCredentials, + error_code: str, +) -> None: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + save_credentials(credentials) + + response = SimpleNamespace( + ok=False, + json=lambda: { + "detail": { + "error_code": error_code, + "message": "Saved API key is no longer valid", + } + }, + ) + + monkeypatch.setattr(cli.requests, "get", lambda *args, **kwargs: response) + monkeypatch.setattr( + cli, + "_open_browser_silently", + lambda url: pytest.fail(f"Invalid setup should not open a browser: {url}"), + ) + + with pytest.raises(RuntimeError, match=r"cytetype setup --force"): + cli._run_setup(credentials.apiUrl) + + assert load_credentials() == credentials + + +def test_setup_reports_validation_network_errors_without_changing_credentials( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + credentials: StoredCredentials, +) -> None: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + save_credentials(credentials) + + def fail_validation(*args: object, **kwargs: object) -> None: + raise cli.requests.ConnectionError("offline") + + monkeypatch.setattr(cli.requests, "get", fail_validation) + monkeypatch.setattr( + cli, + "_open_browser_silently", + lambda url: pytest.fail(f"Failed validation should not open a browser: {url}"), + ) + + with pytest.raises(RuntimeError, match="Could not validate the saved API key"): + cli._run_setup(credentials.apiUrl) + + assert load_credentials() == credentials def test_setup_completes_callback_exchange_without_exposing_key( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], + credentials: StoredCredentials, ) -> None: monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + save_credentials(credentials) opened_urls: list[str] = [] callback_thread: threading.Thread | None = None exchange_body: dict[str, str] = {} @@ -270,9 +384,16 @@ def call_back() -> None: return True monkeypatch.setattr(cli.requests, "post", fake_post) + monkeypatch.setattr( + cli.requests, + "get", + lambda *args, **kwargs: pytest.fail( + "Forced setup must not validate saved credentials" + ), + ) monkeypatch.setattr(cli, "_open_browser_silently", fake_browser_open) - result = cli._run_setup("https://dev.cytetype.example") + result = cli._run_setup("https://dev.cytetype.example", force=True) assert callback_thread is not None callback_thread.join(timeout=5) @@ -474,7 +595,7 @@ def test_setup_keyboard_interrupt_exits_without_traceback( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: - def interrupt_setup(api_url: str) -> None: + def interrupt_setup(api_url: str, force: bool = False) -> None: raise KeyboardInterrupt monkeypatch.setattr(cli, "_run_setup", interrupt_setup)