diff --git a/.gitignore b/.gitignore index 76612a4..12b914d 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ htmlcov/ # Working data (not part of plugin) data/ + +# Sphinx build output +docs/_build/ diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..4807cca --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,23 @@ +# Read the Docs configuration for htan +# https://docs.readthedocs.io/en/stable/config-file/v2.html + +version: 2 + +build: + os: ubuntu-22.04 + tools: + python: "3.12" + +sphinx: + configuration: docs/conf.py + fail_on_warning: false + +formats: + - htmlzip + +python: + install: + - method: pip + path: . + extra_requirements: + - docs diff --git a/docs/_static/.gitkeep b/docs/_static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/api/config.md b/docs/api/config.md new file mode 100644 index 0000000..147e962 --- /dev/null +++ b/docs/api/config.md @@ -0,0 +1,6 @@ +# `htan.config` + +```{eval-rst} +.. automodule:: htan.config + :members: +``` diff --git a/docs/api/download.gen3.md b/docs/api/download.gen3.md new file mode 100644 index 0000000..f99110a --- /dev/null +++ b/docs/api/download.gen3.md @@ -0,0 +1,7 @@ +# `htan.download.gen3` + +```{eval-rst} +.. automodule:: htan.download.gen3 + :members: + :exclude-members: cli_main, gen3, download_cmd, resolve_cmd +``` diff --git a/docs/api/download.synapse.md b/docs/api/download.synapse.md new file mode 100644 index 0000000..c6236d1 --- /dev/null +++ b/docs/api/download.synapse.md @@ -0,0 +1,7 @@ +# `htan.download.synapse` + +```{eval-rst} +.. automodule:: htan.download.synapse + :members: + :exclude-members: cli_main, synapse +``` diff --git a/docs/api/files.md b/docs/api/files.md new file mode 100644 index 0000000..e5aade9 --- /dev/null +++ b/docs/api/files.md @@ -0,0 +1,7 @@ +# `htan.files` + +```{eval-rst} +.. automodule:: htan.files + :members: + :exclude-members: cli_main, files, update_cmd, lookup_cmd, stats_cmd +``` diff --git a/docs/api/index.md b/docs/api/index.md new file mode 100644 index 0000000..1ea7194 --- /dev/null +++ b/docs/api/index.md @@ -0,0 +1,18 @@ +# API reference + +Python API for each `htan` module. Everything the CLI does is also available +as a normal Python import. + +```{toctree} +:maxdepth: 1 + +config +query.portal +query.bq +download.synapse +download.gen3 +pubs +model +files +init +``` diff --git a/docs/api/init.md b/docs/api/init.md new file mode 100644 index 0000000..6398ff4 --- /dev/null +++ b/docs/api/init.md @@ -0,0 +1,10 @@ +# `htan.init` + +First-run setup wizard implementation. Most users will invoke this through +the [`htan init`](../cli/index.md) CLI rather than calling it directly. + +```{eval-rst} +.. automodule:: htan.init + :members: + :exclude-members: cli_main, init +``` diff --git a/docs/api/model.md b/docs/api/model.md new file mode 100644 index 0000000..a638837 --- /dev/null +++ b/docs/api/model.md @@ -0,0 +1,7 @@ +# `htan.model` + +```{eval-rst} +.. automodule:: htan.model + :members: + :exclude-members: cli_main, model, fetch_cmd, components_cmd, attributes_cmd, describe_cmd, valid_values_cmd, search_cmd, required_cmd, deps_cmd +``` diff --git a/docs/api/pubs.md b/docs/api/pubs.md new file mode 100644 index 0000000..f1c98b8 --- /dev/null +++ b/docs/api/pubs.md @@ -0,0 +1,7 @@ +# `htan.pubs` + +```{eval-rst} +.. automodule:: htan.pubs + :members: + :exclude-members: cli_main, pubs, search_cmd, fetch_cmd, fulltext_cmd +``` diff --git a/docs/api/query.bq.md b/docs/api/query.bq.md new file mode 100644 index 0000000..4a8b74b --- /dev/null +++ b/docs/api/query.bq.md @@ -0,0 +1,7 @@ +# `htan.query.bq` + +```{eval-rst} +.. automodule:: htan.query.bq + :members: + :exclude-members: cli_main, bq, query_cmd, sql_cmd, tables_cmd, describe_cmd +``` diff --git a/docs/api/query.portal.md b/docs/api/query.portal.md new file mode 100644 index 0000000..6b4c322 --- /dev/null +++ b/docs/api/query.portal.md @@ -0,0 +1,7 @@ +# `htan.query.portal` + +```{eval-rst} +.. automodule:: htan.query.portal + :members: + :exclude-members: cli_main, portal, files, demographics, diagnosis, cases, specimen, summary, sql_cmd, tables, describe, manifest +``` diff --git a/docs/cli/index.md b/docs/cli/index.md new file mode 100644 index 0000000..3594d92 --- /dev/null +++ b/docs/cli/index.md @@ -0,0 +1,12 @@ +# CLI reference + +The `htan` command is built with [Click](https://click.palletsprojects.com). +Every subcommand below is generated from the Click definition via +[`sphinx-click`](https://sphinx-click.readthedocs.io) — these pages always +match what `htan ... --help` prints in your terminal. + +```{eval-rst} +.. click:: htan.cli:cli + :prog: htan + :nested: full +``` diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..7e7d938 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,68 @@ +"""Sphinx configuration for the htan documentation.""" + +from __future__ import annotations + +import importlib.metadata + +project = "htan" +author = "HTAN DCC" +copyright = "2026, HTAN DCC" + +try: + release = importlib.metadata.version("htan") +except importlib.metadata.PackageNotFoundError: + release = "0.0.0" +version = ".".join(release.split(".")[:2]) + +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.autosummary", + "sphinx.ext.napoleon", + "sphinx.ext.intersphinx", + "sphinx.ext.viewcode", + "sphinx_click", + "myst_parser", +] + +source_suffix = { + ".rst": "restructuredtext", + ".md": "markdown", +} + +# Mock heavy / optional imports so autodoc works on Read the Docs without them. +autodoc_mock_imports = [ + "synapseclient", + "gen3", + "google", + "pandas", + "db_dtypes", + "certifi", +] + +autodoc_default_options = { + "members": True, + "undoc-members": False, + "show-inheritance": True, + "member-order": "bysource", +} +autosummary_generate = True +napoleon_google_docstring = True +napoleon_numpy_docstring = True + +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), + "click": ("https://click.palletsprojects.com/en/stable/", None), + "pandas": ("https://pandas.pydata.org/docs/", None), +} + +html_theme = "furo" +html_title = f"htan {release}" +html_static_path = ["_static"] + +# Don't fail the build on the missing _static dir on a fresh checkout. +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +myst_enable_extensions = ["colon_fence", "deflist"] + +# Sphinx-click introspects Click groups by import path. +sphinx_click_attrs = ["cli"] diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..72acf26 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,59 @@ +# htan + +Python tools for accessing Human Tumor Atlas Network (HTAN) data. + +The `htan` package provides: + +- **A unified `htan` CLI** for the HTAN portal database, BigQuery metadata, + Synapse and CRDC/Gen3 downloads, the data model, and PubMed publication search. +- **A Python library** wrapping the same functionality, suitable for use in + notebooks and pipelines. + +```{tip} +New to the project? Start with [Installation](install.md), then +[Quickstart](quickstart.md), then look up specific commands in the [CLI +reference](cli/index.md). +``` + +## At a glance + +```bash +pip install htan +htan init # First-run wizard +htan query portal files --organ Breast --limit 10 +htan query bq sql "SELECT COUNT(*) FROM ..." +htan download synapse syn26535909 +htan download gen3 download "drs://dg.4DFC/" +htan pubs search --keyword "spatial transcriptomics" +htan model components +htan files lookup HTA9_1_19512 +``` + +## Data access tiers + +HTAN data has multiple access levels. The portal provides a unified query +interface; downloads route through Synapse (open access) or CRDC/Gen3 +(controlled access). + +| Tier | Source | Auth | Module | +|------|--------|------|--------| +| Portal metadata + file discovery | ClickHouse | Synapse team membership | {mod}`htan.query.portal` | +| Open access (de-identified, processed) | Synapse | PAT | {mod}`htan.download.synapse` | +| Controlled access (raw, protected) | CRDC/Gen3 | dbGaP + Gen3 creds | {mod}`htan.download.gen3` | +| Metadata query | BigQuery (`isb-cgc-bq`) | ADC | {mod}`htan.query.bq` | + +```{toctree} +:maxdepth: 2 +:caption: User guide + +install +quickstart +``` + +```{toctree} +:maxdepth: 2 +:caption: Reference + +cli/index +api/index +``` diff --git a/docs/install.md b/docs/install.md new file mode 100644 index 0000000..d7b399f --- /dev/null +++ b/docs/install.md @@ -0,0 +1,49 @@ +# Installation + +The `htan` package is published on PyPI as [`htan`](https://pypi.org/project/htan/) +and requires Python 3.10 or newer. + +## Quick install + +```bash +pip install htan +``` + +This installs the CLI and library along with all default dependencies +(Synapse client, Gen3 SDK, Google BigQuery client, pandas). + +## With `uv` (recommended for development) + +```bash +uv pip install htan # in an active venv +uv pip install -e ".[dev,docs]" # editable, with test + docs deps +``` + +## First-run setup + +After installing, run the interactive wizard: + +```bash +htan init +``` + +This walks through credential setup for each backend (Synapse, portal +ClickHouse, BigQuery, CRDC/Gen3). You can rerun it at any point with +`htan init --force` or check the current state with `htan init --status`. + +Credentials live in the conventional locations: + +| Service | Location | +|---------|----------| +| Portal ClickHouse | OS keychain or `~/.config/htan-skill/portal.json` | +| Synapse | `SYNAPSE_AUTH_TOKEN` env var or `~/.synapseConfig` | +| BigQuery | `gcloud auth application-default login` (or service account JSON) | +| CRDC/Gen3 | `~/.gen3/credentials.json` (download from CRDC after dbGaP auth) | + +## Verifying the install + +```bash +htan --version +htan config check +htan query portal tables # requires portal credentials +``` diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..5dc0031 --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,77 @@ +# Quickstart + +This page walks through a complete end-to-end workflow: discover files via the +portal, look up download coordinates, then fetch a file from Synapse. + +## 1. Configure credentials + +```bash +htan init +htan config check +``` + +## 2. Find files of interest + +The HTAN portal database is the most direct entry point. Filter by organ, +assay, atlas, or any other column on the `files` table. + +```bash +htan query portal tables # Show all tables +htan query portal describe files # Schema for files +htan query portal files \ + --organ Breast \ + --assay "scRNA-seq" \ + --level "Level 1" \ + --output json \ + --limit 5 +``` + +For ad-hoc analytical queries, use `sql`: + +```bash +htan query portal sql \ + "SELECT atlas_name, COUNT(*) AS n FROM files GROUP BY atlas_name ORDER BY n DESC" +``` + +## 3. Generate a download manifest + +```bash +htan query portal manifest HTA9_1_19512 HTA9_1_19553 --output-dir ./manifests +``` + +This writes `synapse_manifest.tsv` and/or `gen3_manifest.json` depending on +which platform each file lives on. + +## 4. Download + +For open-access files (Synapse): + +```bash +htan download synapse syn26535909 --output-dir ./data +``` + +For controlled-access files (CRDC/Gen3): + +```bash +htan download gen3 download "drs://dg.4DFC/" \ + --credentials ~/.gen3/credentials.json \ + --output-dir ./data +``` + +## 5. Use the library directly + +Everything the CLI does is also exposed as Python: + +```python +from htan.query.portal import PortalClient + +client = PortalClient() +files = client.find_files(organ="Breast", assay="scRNA-seq", limit=10) +for row in files: + print(row["DataFileID"], row["Filename"]) +``` + +## See also + +- [CLI reference](cli/index.md) — the full command tree, generated from Click. +- [API reference](api/index.md) — module-by-module Python API. diff --git a/pyproject.toml b/pyproject.toml index 0424b50..8038077 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ readme = "README.md" license = "MIT" requires-python = ">=3.10" dependencies = [ + "click>=8.1", "synapseclient>=4.0", "gen3>=4.27", "google-cloud-bigquery>=3.40", @@ -20,6 +21,12 @@ dependencies = [ [project.optional-dependencies] dev = ["pytest>=7.0", "ruff>=0.1"] +docs = [ + "sphinx>=7.3", + "furo>=2024.5", + "sphinx-click>=6.0", + "myst-parser>=3.0", +] [project.urls] Homepage = "https://github.com/ncihtan/htan-cli" diff --git a/src/htan/cli.py b/src/htan/cli.py index 9181c04..cb59b4d 100644 --- a/src/htan/cli.py +++ b/src/htan/cli.py @@ -1,139 +1,111 @@ """Unified CLI for HTAN tools. -Entry point: `htan` command (installed via pip install htan). - -Subcommands: - htan query portal ... — Portal ClickHouse queries - htan query bq ... — BigQuery queries - htan download synapse ... — Synapse open-access downloads - htan download gen3 ... — Gen3/CRDC controlled-access downloads - htan pubs ... — PubMed publication search - htan model ... — HTAN data model queries - htan files ... — File ID to download coordinate mapping - htan config ... — Credential status and setup +Entry point: ``htan`` command (installed via ``pip install htan``). + +Subcommands:: + + htan query portal ... Portal ClickHouse queries + htan query bq ... BigQuery queries + htan download synapse ... Synapse open-access downloads + htan download gen3 ... Gen3/CRDC controlled-access downloads + htan pubs ... PubMed publication search + htan model ... HTAN data model queries + htan files ... File ID to download coordinate mapping + htan init ... First-run setup wizard + htan config check Credential status + +Built on Click — the top-level :data:`cli` group composes subgroups defined in +each submodule. Submodules also expose ``cli_main(argv)`` shims for callers +that want to invoke a subtree directly with a list of args. """ -import sys +import json +import click -def main(): - if len(sys.argv) < 2: - _print_usage() - sys.exit(1) - - command = sys.argv[1] - rest = sys.argv[2:] - - if command in ("-h", "--help", "help"): - _print_usage() - return - - if command == "--version": - from htan import __version__ - print(f"htan {__version__}") - return - - if command == "init": - from htan.init import cli_main - cli_main(rest) - elif command == "query": - _dispatch_query(rest) - elif command == "download": - _dispatch_download(rest) - elif command == "pubs": - from htan.pubs import cli_main - cli_main(rest) - elif command == "model": - from htan.model import cli_main - cli_main(rest) - elif command == "files": - from htan.files import cli_main - cli_main(rest) - elif command == "config": - _dispatch_config(rest) - else: - print(f"Unknown command: {command}", file=sys.stderr) - _print_usage() - sys.exit(1) - - -def _dispatch_query(args): - if not args: - print("Usage: htan query {portal,bq} ...", file=sys.stderr) - sys.exit(1) - - backend = args[0] - rest = args[1:] - - if backend == "portal": - from htan.query.portal import cli_main - cli_main(rest) - elif backend == "bq": - from htan.query.bq import cli_main - cli_main(rest) - else: - print(f"Unknown query backend: {backend}. Use 'portal' or 'bq'.", file=sys.stderr) - sys.exit(1) - - -def _dispatch_download(args): - if not args: - print("Usage: htan download {synapse,gen3} ...", file=sys.stderr) - sys.exit(1) - - backend = args[0] - rest = args[1:] - - if backend == "synapse": - from htan.download.synapse import cli_main - cli_main(rest) - elif backend == "gen3": - from htan.download.gen3 import cli_main - cli_main(rest) - else: - print(f"Unknown download backend: {backend}. Use 'synapse' or 'gen3'.", file=sys.stderr) - sys.exit(1) - - -def _dispatch_config(args): - import json +from htan import __version__ + + +@click.group(context_settings={"help_option_names": ["-h", "--help"]}) +@click.version_option(__version__, "-V", "--version", prog_name="htan") +def cli(): + """HTAN — Python tools for accessing Human Tumor Atlas Network data.""" + + +# --- query -------------------------------------------------------------- + +@cli.group() +def query(): + """Query HTAN data via the portal or BigQuery.""" + + +# Subcommands attached lazily so heavy imports stay lazy. +def _add_query_subcommands(): + from htan.query.portal import portal + from htan.query.bq import bq + query.add_command(portal, name="portal") + query.add_command(bq, name="bq") + + +_add_query_subcommands() + + +# --- download ----------------------------------------------------------- + +@cli.group() +def download(): + """Download HTAN files from Synapse or Gen3/CRDC.""" + + +def _add_download_subcommands(): + from htan.download.synapse import synapse + from htan.download.gen3 import gen3 + download.add_command(synapse, name="synapse") + download.add_command(gen3, name="gen3") + + +_add_download_subcommands() + + +# --- pubs / model / files / init --------------------------------------- + +def _add_top_level_subcommands(): + from htan.pubs import pubs + from htan.model import model + from htan.files import files + from htan.init import init + cli.add_command(pubs, name="pubs") + cli.add_command(model, name="model") + cli.add_command(files, name="files") + cli.add_command(init, name="init") + + +_add_top_level_subcommands() + + +# --- config ------------------------------------------------------------- + +@cli.group() +def config(): + """Credential configuration.""" + + +@config.command(name="check") +def config_check(): + """Print the current credential configuration as JSON.""" from htan.config import check_setup + status = check_setup() + click.echo(json.dumps({"ok": True, "status": status}, indent=2)) + - if args and args[0] in ("-h", "--help"): - print("Usage: htan config check") - print(" htan config init-portal") - return - - command = args[0] if args else "check" - - if command == "check": - status = check_setup() - print(json.dumps({"ok": True, "status": status}, indent=2)) - elif command == "init-portal": - print("Deprecated: use 'htan init portal' instead.", file=sys.stderr) - from htan.init import cli_main as init_main - init_main(["portal"]) - else: - print(f"Unknown config command: {command}", file=sys.stderr) - sys.exit(1) - - -def _print_usage(): - print("""Usage: htan [args...] - -Commands: - init Interactive setup wizard (configure credentials) - query portal ... Query HTAN portal ClickHouse database - query bq ... Query HTAN metadata in ISB-CGC BigQuery - download synapse .. Download open-access files from Synapse - download gen3 ... Download controlled-access files from Gen3/CRDC - pubs ... Search HTAN publications on PubMed - model ... Query HTAN data model (components, attributes, valid values) - files ... Map HTAN file IDs to download coordinates - config check Check credential configuration status - -Options: - --help Show this help message - --version Show version - -Run 'htan --help' for command-specific help.""") +@config.command(name="init-portal", hidden=True) +def config_init_portal(): + """Deprecated alias for ``htan init portal``.""" + click.echo("Deprecated: use 'htan init portal' instead.", err=True) + from htan.init import cli_main as init_main + init_main(["portal"]) + + +def main(): + """Entry point for the ``htan`` script (defined in pyproject.toml).""" + cli(prog_name="htan") diff --git a/src/htan/download/gen3.py b/src/htan/download/gen3.py index 78e57b6..190d48c 100644 --- a/src/htan/download/gen3.py +++ b/src/htan/download/gen3.py @@ -1,24 +1,25 @@ """Download HTAN controlled-access data from CRDC/Gen3 via DRS URIs. -Requires: pip install htan[gen3] +Usage as library:: -Usage as library: from htan.download.gen3 import download, resolve path = download("drs://dg.4DFC/guid-here", output_dir="./data") url = resolve("drs://dg.4DFC/guid-here") -Usage as CLI: - htan download gen3 "drs://dg.4DFC/guid-here" - htan download gen3 "drs://dg.4DFC/guid-here" --dry-run +Usage as CLI:: + + htan download gen3 download "drs://dg.4DFC/guid-here" + htan download gen3 resolve "drs://dg.4DFC/guid-here" """ -import argparse import json import os import re import sys import urllib.request +import click + GEN3_ENDPOINT = "https://nci-crdc.datacommons.io" DRS_URI_PATTERN = re.compile(r"^drs://(dg\.4DFC|nci-crdc\.datacommons\.io/dg\.4DFC)/[a-zA-Z0-9._/\-]+$") @@ -170,69 +171,79 @@ def download(drs_uri, output_dir=".", credentials=None, protocol="s3", dry_run=F # --- CLI --- -def cli_main(argv=None): - """CLI entry point for Gen3/CRDC downloads.""" - parser = argparse.ArgumentParser( - description="Download HTAN controlled-access data from CRDC/Gen3", - epilog="Examples:\n" - ' htan download gen3 "drs://dg.4DFC/guid" --credentials creds.json\n' - ' htan download gen3 "drs://dg.4DFC/guid" --dry-run\n', - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - subparsers = parser.add_subparsers(dest="command", required=True) - - sp_dl = subparsers.add_parser("download", help="Download files by DRS URI") - sp_dl.add_argument("drs_uri", nargs="?", help="DRS URI") - sp_dl.add_argument("--manifest", "-m", help="File with DRS URIs (one per line)") - sp_dl.add_argument("--credentials", "-c", help="Path to Gen3 credentials JSON") - sp_dl.add_argument("--output-dir", "-o", default=".", help="Output directory") - sp_dl.add_argument("--protocol", choices=["s3", "gs"], default="s3") - sp_dl.add_argument("--dry-run", action="store_true") - - sp_res = subparsers.add_parser("resolve", help="Resolve DRS URI to signed URL") - sp_res.add_argument("drs_uri", help="DRS URI to resolve") - sp_res.add_argument("--credentials", "-c", help="Path to Gen3 credentials JSON") - sp_res.add_argument("--protocol", choices=["s3", "gs"], default="s3") - sp_res.add_argument("--dry-run", action="store_true") - - args = parser.parse_args(argv) - - if args.command == "download": - if args.manifest: - # Read URIs from manifest - if not os.path.exists(args.manifest): - print(f"Error: Manifest file not found: {args.manifest}", file=sys.stderr) - sys.exit(1) - uris = [] - with open(args.manifest) as f: - for line in f: - line = line.strip() - if line and not line.startswith("#"): - uris.append(line) - elif args.drs_uri: - uris = [args.drs_uri] - else: - print("Error: Provide a DRS URI or --manifest file.", file=sys.stderr) - sys.exit(1) +_GEN3_EPILOG = """\ +Examples: + + htan download gen3 download "drs://dg.4DFC/guid" --credentials creds.json + htan download gen3 download "drs://dg.4DFC/guid" --dry-run + htan download gen3 resolve "drs://dg.4DFC/guid" +""" + - downloaded = [] - for i, uri in enumerate(uris, 1): - if len(uris) > 1: - print(f"\n[{i}/{len(uris)}]", file=sys.stderr) - path = download(uri, output_dir=args.output_dir, credentials=args.credentials, - protocol=args.protocol, dry_run=args.dry_run) - if path: - downloaded.append(path) - print(path) - - elif args.command == "resolve": - if args.dry_run: - _validate_drs_uri(args.drs_uri) - guid = _extract_guid(args.drs_uri) - print(f"Dry run — would resolve:", file=sys.stderr) - print(f" DRS URI: {args.drs_uri}", file=sys.stderr) - print(f" GUID: {guid}", file=sys.stderr) - return - - url = resolve(args.drs_uri, credentials=args.credentials, protocol=args.protocol) - print(url) +@click.group(name="gen3", epilog=_GEN3_EPILOG) +def gen3(): + """Download HTAN controlled-access data from CRDC/Gen3.""" + + +@gen3.command(name="download") +@click.argument("drs_uri", required=False) +@click.option("--manifest", "-m", help="File with DRS URIs (one per line)") +@click.option("--credentials", "-c", help="Path to Gen3 credentials JSON") +@click.option("--output-dir", "-o", "output_dir", default=".", show_default=True) +@click.option("--protocol", type=click.Choice(["s3", "gs"]), default="s3", show_default=True) +@click.option("--dry-run", "dry_run", is_flag=True) +def download_cmd(drs_uri, manifest, credentials, output_dir, protocol, dry_run): + """Download files by DRS URI (or via a manifest file).""" + if manifest: + if not os.path.exists(manifest): + click.echo(f"Error: Manifest file not found: {manifest}", err=True) + raise click.exceptions.Exit(1) + uris = [] + with open(manifest) as f: + for line in f: + line = line.strip() + if line and not line.startswith("#"): + uris.append(line) + elif drs_uri: + uris = [drs_uri] + else: + click.echo("Error: Provide a DRS URI or --manifest file.", err=True) + raise click.exceptions.Exit(1) + + for i, uri in enumerate(uris, 1): + if len(uris) > 1: + click.echo(f"\n[{i}/{len(uris)}]", err=True) + path = download(uri, output_dir=output_dir, credentials=credentials, + protocol=protocol, dry_run=dry_run) + if path: + click.echo(path) + + +@gen3.command(name="resolve") +@click.argument("drs_uri") +@click.option("--credentials", "-c", help="Path to Gen3 credentials JSON") +@click.option("--protocol", type=click.Choice(["s3", "gs"]), default="s3", show_default=True) +@click.option("--dry-run", "dry_run", is_flag=True) +def resolve_cmd(drs_uri, credentials, protocol, dry_run): + """Resolve DRS URI to a signed download URL.""" + if dry_run: + _validate_drs_uri(drs_uri) + guid = _extract_guid(drs_uri) + click.echo("Dry run — would resolve:", err=True) + click.echo(f" DRS URI: {drs_uri}", err=True) + click.echo(f" GUID: {guid}", err=True) + return + + url = resolve(drs_uri, credentials=credentials, protocol=protocol) + click.echo(url) + + +def cli_main(argv=None): + """Backward-compatible entry point — invokes the Click :data:`gen3` group.""" + try: + return gen3.main(args=argv, prog_name="htan download gen3", standalone_mode=False) + except click.exceptions.Exit as e: + sys.exit(e.exit_code) + except click.exceptions.ClickException as e: + e.show() + sys.exit(e.exit_code) diff --git a/src/htan/download/synapse.py b/src/htan/download/synapse.py index 1e01673..c7f51c1 100644 --- a/src/htan/download/synapse.py +++ b/src/htan/download/synapse.py @@ -1,21 +1,22 @@ """Download HTAN open-access files from Synapse by entity ID. -Requires: pip install htan[synapse] +Usage as library:: -Usage as library: from htan.download.synapse import download path = download("syn26535909", output_dir="./data") -Usage as CLI: +Usage as CLI:: + htan download synapse syn26535909 htan download synapse syn26535909 --output-dir ./data --dry-run """ -import argparse import os import re import sys +import click + SYNAPSE_ID_PATTERN = re.compile(r"^syn\d+$") @@ -99,21 +100,34 @@ def download(synapse_id, output_dir=".", dry_run=False): # --- CLI --- -def cli_main(argv=None): - """CLI entry point for Synapse downloads.""" - parser = argparse.ArgumentParser( - description="Download HTAN open-access files from Synapse", - epilog="Examples:\n" - " htan download synapse syn26535909\n" - " htan download synapse syn26535909 --output-dir ./data\n" - " htan download synapse syn26535909 --dry-run\n", - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - parser.add_argument("synapse_id", help="Synapse entity ID (e.g., syn26535909)") - parser.add_argument("--output-dir", "-o", default=".", help="Output directory") - parser.add_argument("--dry-run", action="store_true", help="Show metadata without downloading") - - args = parser.parse_args(argv) - path = download(args.synapse_id, output_dir=args.output_dir, dry_run=args.dry_run) +_SYNAPSE_EPILOG = """\ +Examples: + + htan download synapse syn26535909 + htan download synapse syn26535909 --output-dir ./data + htan download synapse syn26535909 --dry-run +""" + + +@click.command(name="synapse", epilog=_SYNAPSE_EPILOG) +@click.argument("synapse_id") +@click.option("--output-dir", "-o", "output_dir", default=".", show_default=True, + help="Output directory") +@click.option("--dry-run", "dry_run", is_flag=True, + help="Show metadata without downloading") +def synapse(synapse_id, output_dir, dry_run): + """Download HTAN open-access files from Synapse by entity ID.""" + path = download(synapse_id, output_dir=output_dir, dry_run=dry_run) if path: - print(path) + click.echo(path) + + +def cli_main(argv=None): + """Backward-compatible entry point — invokes the Click :data:`synapse` command.""" + try: + return synapse.main(args=argv, prog_name="htan download synapse", standalone_mode=False) + except click.exceptions.Exit as e: + sys.exit(e.exit_code) + except click.exceptions.ClickException as e: + e.show() + sys.exit(e.exit_code) diff --git a/src/htan/files.py b/src/htan/files.py index ff8689e..48cae62 100644 --- a/src/htan/files.py +++ b/src/htan/files.py @@ -5,18 +5,19 @@ No extra dependencies — uses only stdlib (urllib, json). -Usage as library: +Usage as library:: + from htan.files import lookup, update_cache, stats results = lookup(["HTA9_1_19512"]) update_cache() -Usage as CLI: +Usage as CLI:: + htan files lookup HTA9_1_19512 htan files update htan files stats """ -import argparse import json import os import re @@ -24,6 +25,8 @@ import urllib.error import urllib.request +import click + MAPPING_URL = ( "https://raw.githubusercontent.com/ncihtan/htan-portal/" "4ce608118116f3e074415ef00a82bd460a9ba9ee/" @@ -228,70 +231,86 @@ def _format_json_output(results): return json.dumps(output, indent=2) -def cli_main(argv=None): - """CLI entry point for file mapping.""" - parser = argparse.ArgumentParser( - description="HTAN file mapping: resolve HTAN_Data_File_ID to download coordinates", - epilog="Examples:\n" - " htan files update\n" - " htan files lookup HTA9_1_19512\n" - " htan files lookup HTA9_1_19512 HTA9_1_19553 --format json\n" - " htan files stats\n", - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - subparsers = parser.add_subparsers(dest="command", required=True) - - subparsers.add_parser("update", help="Download or refresh the mapping cache") - - sp_lookup = subparsers.add_parser("lookup", help="Look up HTAN_Data_File_IDs") - sp_lookup.add_argument("ids", nargs="*", help="HTAN_Data_File_IDs") - sp_lookup.add_argument("--file", "-f", help="File containing IDs (one per line)") - sp_lookup.add_argument("--format", choices=["text", "json"], default="text", help="Output format") - - subparsers.add_parser("stats", help="Show mapping statistics") - - args = parser.parse_args(argv) - - if args.command == "update": - update_cache() - elif args.command == "lookup": - file_ids = list(args.ids) if args.ids else [] - if args.file: - try: - with open(args.file, "r") as f: - for line in f: - line = line.strip() - if line and not line.startswith("#"): - file_ids.append(line) - except FileNotFoundError: - print(f"Error: File not found: {args.file}", file=sys.stderr) - sys.exit(1) - - if not file_ids: - print("Error: No file IDs provided.", file=sys.stderr) - sys.exit(1) +_FILES_EPILOG = """\ +Examples: + + htan files update + htan files lookup HTA9_1_19512 + htan files lookup HTA9_1_19512 HTA9_1_19553 --format json + htan files stats +""" - for fid in file_ids: - if not FILE_ID_PATTERN.match(fid): - print(f"Warning: '{fid}' does not match expected format (HTA*_*_*)", file=sys.stderr) - results = lookup(file_ids) - if not results: - print("No matching records found.", file=sys.stderr) - sys.exit(1) +@click.group(name="files", epilog=_FILES_EPILOG) +def files(): + """HTAN file mapping: resolve HTAN_Data_File_ID to download coordinates.""" - print(f"Found {len(results)}/{len(file_ids)} files", file=sys.stderr) - if args.format == "json": - print(_format_json_output(results)) - else: - print(_format_text_output(results)) - - elif args.command == "stats": - s = stats() - print(f"Total files: {s['total_files']:,}") - print(f"With Synapse entityId: {s['with_synapse_entity_id']:,}") - print(f"With DRS URI (Gen3): {s['with_drs_uri']:,}") - print() - print("Files per center:") - for center, count in s["files_per_center"].items(): - print(f" {center:<25} {count:>6,}") + +@files.command(name="update") +def update_cmd(): + """Download or refresh the mapping cache.""" + update_cache() + + +@files.command(name="lookup") +@click.argument("ids", nargs=-1) +@click.option("--file", "-f", "file_path", + help="File containing IDs (one per line)") +@click.option("--format", "fmt", type=click.Choice(["text", "json"]), + default="text", show_default=True, help="Output format") +def lookup_cmd(ids, file_path, fmt): + """Look up HTAN_Data_File_IDs.""" + file_ids = list(ids) if ids else [] + if file_path: + try: + with open(file_path, "r") as f: + for line in f: + line = line.strip() + if line and not line.startswith("#"): + file_ids.append(line) + except FileNotFoundError: + click.echo(f"Error: File not found: {file_path}", err=True) + raise click.exceptions.Exit(1) + + if not file_ids: + click.echo("Error: No file IDs provided.", err=True) + raise click.exceptions.Exit(1) + + for fid in file_ids: + if not FILE_ID_PATTERN.match(fid): + click.echo(f"Warning: '{fid}' does not match expected format (HTA*_*_*)", err=True) + + results = lookup(file_ids) + if not results: + click.echo("No matching records found.", err=True) + raise click.exceptions.Exit(1) + + click.echo(f"Found {len(results)}/{len(file_ids)} files", err=True) + if fmt == "json": + click.echo(_format_json_output(results)) + else: + click.echo(_format_text_output(results)) + + +@files.command(name="stats") +def stats_cmd(): + """Show mapping statistics.""" + s = stats() + click.echo(f"Total files: {s['total_files']:,}") + click.echo(f"With Synapse entityId: {s['with_synapse_entity_id']:,}") + click.echo(f"With DRS URI (Gen3): {s['with_drs_uri']:,}") + click.echo() + click.echo("Files per center:") + for center, count in s["files_per_center"].items(): + click.echo(f" {center:<25} {count:>6,}") + + +def cli_main(argv=None): + """Backward-compatible entry point — invokes the Click :data:`files` group.""" + try: + return files.main(args=argv, prog_name="htan files", standalone_mode=False) + except click.exceptions.Exit as e: + sys.exit(e.exit_code) + except click.exceptions.ClickException as e: + e.show() + sys.exit(e.exit_code) diff --git a/src/htan/init.py b/src/htan/init.py index ad06ad3..4cfe96b 100644 --- a/src/htan/init.py +++ b/src/htan/init.py @@ -15,7 +15,6 @@ htan init --force # Re-run even if configured """ -import argparse import base64 import json import os @@ -26,6 +25,8 @@ import urllib.parse import urllib.request +import click + from htan.config import ( check_setup, detect_source, @@ -624,65 +625,51 @@ def run_init(services=None, force=False, non_interactive=False, status_only=Fals # CLI entry point # --------------------------------------------------------------------------- -def cli_main(args=None): - """Argparse entry point for ``htan init``. +_INIT_EPILOG = """\ +Examples: - Args: - args: List of arguments (default: sys.argv style from CLI dispatcher). - """ - parser = argparse.ArgumentParser( - prog="htan init", - description="Interactive setup wizard for HTAN CLI credentials", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=( - "Examples:\n" - " htan init # Full interactive wizard\n" - " htan init portal # Set up portal only\n" - " htan init synapse # Set up synapse only\n" - " htan init --status # Show current config status\n" - " htan init --non-interactive # CI mode: detect only\n" - " htan init --force # Re-run even if configured\n" - ), - ) - parser.add_argument( - "service", - nargs="?", - choices=["portal", "synapse", "bigquery", "gen3"], - default=None, - help="Set up a specific service (default: interactive menu)", - ) - parser.add_argument( - "--status", - action="store_true", - help="Show current configuration status and exit", - ) - parser.add_argument( - "--force", - action="store_true", - help="Re-run setup even if already configured", - ) - parser.add_argument( - "--non-interactive", - action="store_true", - help="Detect existing config only — no interactive prompts (CI mode)", - ) + htan init # Full interactive wizard + htan init portal # Set up portal only + htan init synapse # Set up synapse only + htan init --status # Show current config status + htan init --non-interactive # CI mode: detect only + htan init --force # Re-run even if configured +""" - parsed = parser.parse_args(args) - services = [parsed.service] if parsed.service else None +@click.command(name="init", epilog=_INIT_EPILOG) +@click.argument("service", required=False, + type=click.Choice(["portal", "synapse", "bigquery", "gen3"])) +@click.option("--status", is_flag=True, + help="Show current configuration status and exit") +@click.option("--force", is_flag=True, + help="Re-run setup even if already configured") +@click.option("--non-interactive", "non_interactive", is_flag=True, + help="Detect existing config only — no interactive prompts (CI mode)") +def init(service, status, force, non_interactive): + """Interactive setup wizard for HTAN CLI credentials.""" + services = [service] if service else None result = run_init( services=services, - force=parsed.force, - non_interactive=parsed.non_interactive, - status_only=parsed.status, + force=force, + non_interactive=non_interactive, + status_only=status, ) - # Exit non-zero if any requested service failed if result and isinstance(result, dict): - # For status_only, exit 0 always - if parsed.status: + if status: return - # For init, exit 1 if any service is False if any(v is False for v in result.values()): - sys.exit(1) + raise click.exceptions.Exit(1) + + +def cli_main(args=None): + """Backward-compatible entry point — invokes the Click :data:`init` command.""" + try: + return init.main(args=args, prog_name="htan init", standalone_mode=False) + except click.exceptions.Exit as e: + sys.exit(e.exit_code) + except click.exceptions.ClickException as e: + e.show() + sys.exit(e.exit_code) diff --git a/src/htan/model.py b/src/htan/model.py index 8c2a12c..6dbcb92 100644 --- a/src/htan/model.py +++ b/src/htan/model.py @@ -1,21 +1,22 @@ """Query the HTAN Phase 1 data model (ncihtan/data-models). Fetches, caches, and queries the data model CSV from a pinned GitHub release tag. -No extra dependencies — uses only stdlib (csv, json, urllib, argparse). +No extra dependencies — uses only stdlib (csv, json, urllib). + +Usage as library:: -Usage as library: from htan.model import DataModel dm = DataModel() components = dm.components() attrs = dm.attributes("scRNA-seq Level 1") -Usage as CLI: +Usage as CLI:: + htan model components htan model attributes "scRNA-seq Level 1" htan model describe "Library Construction Method" """ -import argparse import csv import io import json @@ -25,6 +26,8 @@ import urllib.error import urllib.request +import click + MODEL_TAG = "v25.2.1" MODEL_URL_TEMPLATE = ( "https://raw.githubusercontent.com/ncihtan/data-models/{tag}/HTAN.model.csv" @@ -491,125 +494,168 @@ def render_tree(comp, depth=0): # --- CLI --- -def cli_main(argv=None): - """CLI entry point for data model queries.""" - parser = argparse.ArgumentParser( - description="Query the HTAN Phase 1 data model (ncihtan/data-models)", - epilog="Examples:\n" - " htan model fetch\n" - " htan model components\n" - ' htan model attributes "scRNA-seq Level 1"\n' - ' htan model describe "Library Construction Method"\n' - ' htan model valid-values "File Format"\n' - ' htan model search "barcode"\n', - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - subparsers = parser.add_subparsers(dest="command", required=True) - - def add_common_args(sp): - sp.add_argument("--tag", default=None, help=f"Model version tag (default: {MODEL_TAG})") - sp.add_argument("--format", choices=["text", "json"], default="text", help="Output format") - - sp_fetch = subparsers.add_parser("fetch", help="Download or refresh the model CSV") - sp_fetch.add_argument("--tag", default=None) - sp_fetch.add_argument("--format", choices=["text", "json"], default="text", help=argparse.SUPPRESS) - sp_fetch.add_argument("--dry-run", action="store_true") - - sp_comp = subparsers.add_parser("components", help="List all manifest components") - add_common_args(sp_comp) - - sp_attr = subparsers.add_parser("attributes", help="List attributes for a component") - sp_attr.add_argument("component", help="Component name") - add_common_args(sp_attr) - - sp_desc = subparsers.add_parser("describe", help="Full detail for one attribute") - sp_desc.add_argument("attribute", help="Attribute name") - add_common_args(sp_desc) - - sp_vv = subparsers.add_parser("valid-values", help="List valid values for an attribute") - sp_vv.add_argument("attribute", help="Attribute name") - add_common_args(sp_vv) - - sp_search = subparsers.add_parser("search", help="Search attributes by keyword") - sp_search.add_argument("keyword", help="Keyword to search for") - add_common_args(sp_search) - - sp_req = subparsers.add_parser("required", help="List required attributes for a component") - sp_req.add_argument("component", help="Component name") - add_common_args(sp_req) - - sp_deps = subparsers.add_parser("deps", help="Show dependency chain for a component") - sp_deps.add_argument("component", help="Component name") - add_common_args(sp_deps) - - args = parser.parse_args(argv) - dm = DataModel(tag=args.tag if hasattr(args, "tag") else None) - - if args.command == "fetch": - download_model(tag=args.tag, force=True, dry_run=args.dry_run) - if not args.dry_run: - print(f"Model version: {args.tag or MODEL_TAG}", file=sys.stderr) - elif args.command == "components": - comps = dm.components() - if args.format == "json": - print(json.dumps(comps, indent=2)) - else: - print(_format_components_text(comps)) - elif args.command == "attributes": - comp_name, attrs = dm.attributes(args.component) - if args.format == "json": - print(json.dumps({"component": comp_name, "attributes": attrs}, indent=2)) - else: - print(_format_attributes_text(comp_name, attrs)) - elif args.command == "describe": - detail = dm.describe(args.attribute) - if args.format == "json": - print(json.dumps(detail, indent=2)) - else: - print(_format_describe_text(detail)) - elif args.command == "valid-values": - vv = dm.valid_values(args.attribute) - row = _find_attribute(dm._load(), args.attribute) - attr_name = row["Attribute"] - if args.format == "json": - print(json.dumps({"attribute": attr_name, "valid_values": vv}, indent=2)) - else: - print(f"Valid values for '{attr_name}' ({len(vv)}):") - for v in vv: - print(f" {v}") - if not vv: - print(" (none — free text or computed)") - elif args.command == "search": - results = dm.search(args.keyword) - print(f"Searching for '{args.keyword}'...", file=sys.stderr) - if args.format == "json": - print(json.dumps(results, indent=2)) - else: - if not results: - print("No matches found.") - else: - print(f"{'Attribute':<40} {'Parent':<25} {'Match In'}") - print(f"{'-'*40} {'-'*25} {'-'*15}") - for r in results: - print(f"{r['name']:<40} {r['parent']:<25} {r['match_in']}") - print(f"\n{len(results)} matches") - elif args.command == "required": - comp_name, attrs = dm.attributes(args.component) - required = [a for a in attrs if a["required"]] - if args.format == "json": - print(json.dumps({"component": comp_name, "required_attributes": required}, indent=2)) - else: - optional = [a for a in attrs if not a["required"]] - print(f"Component: {comp_name}") - print(f"Required: {len(required)}, Optional: {len(optional)}, Total: {len(attrs)}") - print("\nRequired attributes:") - for attr in required: - vr = attr["validation_rules"] - suffix = f" [{vr}]" if vr else "" - print(f" {attr['name']}{suffix}") - elif args.command == "deps": - chain = dm.deps(args.component) - if args.format == "json": - print(json.dumps(chain, indent=2)) +_MODEL_EPILOG = """\ +Examples: + + htan model fetch + htan model components + htan model attributes "scRNA-seq Level 1" + htan model describe "Library Construction Method" + htan model valid-values "File Format" + htan model search "barcode" +""" + + +def _tag_option(f): + return click.option("--tag", default=None, + help=f"Model version tag (default: {MODEL_TAG})")(f) + + +def _format_option(f): + return click.option("--format", "fmt", type=click.Choice(["text", "json"]), + default="text", show_default=True, help="Output format")(f) + + +@click.group(name="model", epilog=_MODEL_EPILOG) +def model(): + """Query the HTAN Phase 1 data model.""" + + +@model.command(name="fetch") +@_tag_option +@click.option("--dry-run", "dry_run", is_flag=True) +def fetch_cmd(tag, dry_run): + """Download or refresh the model CSV cache.""" + download_model(tag=tag, force=True, dry_run=dry_run) + if not dry_run: + click.echo(f"Model version: {tag or MODEL_TAG}", err=True) + + +@model.command(name="components") +@_tag_option +@_format_option +def components_cmd(tag, fmt): + """List all manifest components.""" + dm = DataModel(tag=tag) + comps = dm.components() + if fmt == "json": + click.echo(json.dumps(comps, indent=2)) + else: + click.echo(_format_components_text(comps)) + + +@model.command(name="attributes") +@click.argument("component") +@_tag_option +@_format_option +def attributes_cmd(component, tag, fmt): + """List attributes for a component.""" + dm = DataModel(tag=tag) + comp_name, attrs = dm.attributes(component) + if fmt == "json": + click.echo(json.dumps({"component": comp_name, "attributes": attrs}, indent=2)) + else: + click.echo(_format_attributes_text(comp_name, attrs)) + + +@model.command(name="describe") +@click.argument("attribute") +@_tag_option +@_format_option +def describe_cmd(attribute, tag, fmt): + """Full detail for one attribute.""" + dm = DataModel(tag=tag) + detail = dm.describe(attribute) + if fmt == "json": + click.echo(json.dumps(detail, indent=2)) + else: + click.echo(_format_describe_text(detail)) + + +@model.command(name="valid-values") +@click.argument("attribute") +@_tag_option +@_format_option +def valid_values_cmd(attribute, tag, fmt): + """List valid values for an attribute.""" + dm = DataModel(tag=tag) + vv = dm.valid_values(attribute) + row = _find_attribute(dm._load(), attribute) + attr_name = row["Attribute"] + if fmt == "json": + click.echo(json.dumps({"attribute": attr_name, "valid_values": vv}, indent=2)) + else: + click.echo(f"Valid values for '{attr_name}' ({len(vv)}):") + for v in vv: + click.echo(f" {v}") + if not vv: + click.echo(" (none — free text or computed)") + + +@model.command(name="search") +@click.argument("keyword") +@_tag_option +@_format_option +def search_cmd(keyword, tag, fmt): + """Search attributes by keyword.""" + dm = DataModel(tag=tag) + results = dm.search(keyword) + click.echo(f"Searching for '{keyword}'...", err=True) + if fmt == "json": + click.echo(json.dumps(results, indent=2)) + else: + if not results: + click.echo("No matches found.") else: - print(_format_deps_text(chain)) + click.echo(f"{'Attribute':<40} {'Parent':<25} {'Match In'}") + click.echo(f"{'-'*40} {'-'*25} {'-'*15}") + for r in results: + click.echo(f"{r['name']:<40} {r['parent']:<25} {r['match_in']}") + click.echo(f"\n{len(results)} matches") + + +@model.command(name="required") +@click.argument("component") +@_tag_option +@_format_option +def required_cmd(component, tag, fmt): + """List required attributes for a component.""" + dm = DataModel(tag=tag) + comp_name, attrs = dm.attributes(component) + required = [a for a in attrs if a["required"]] + if fmt == "json": + click.echo(json.dumps({"component": comp_name, "required_attributes": required}, indent=2)) + else: + optional = [a for a in attrs if not a["required"]] + click.echo(f"Component: {comp_name}") + click.echo(f"Required: {len(required)}, Optional: {len(optional)}, Total: {len(attrs)}") + click.echo("\nRequired attributes:") + for attr in required: + vr = attr["validation_rules"] + suffix = f" [{vr}]" if vr else "" + click.echo(f" {attr['name']}{suffix}") + + +@model.command(name="deps") +@click.argument("component") +@_tag_option +@_format_option +def deps_cmd(component, tag, fmt): + """Show dependency chain for a component.""" + dm = DataModel(tag=tag) + chain = dm.deps(component) + if fmt == "json": + click.echo(json.dumps(chain, indent=2)) + else: + click.echo(_format_deps_text(chain)) + + +def cli_main(argv=None): + """Backward-compatible entry point — invokes the Click :data:`model` group.""" + try: + return model.main(args=argv, prog_name="htan model", standalone_mode=False) + except click.exceptions.Exit as e: + sys.exit(e.exit_code) + except click.exceptions.ClickException as e: + e.show() + sys.exit(e.exit_code) diff --git a/src/htan/pubs.py b/src/htan/pubs.py index eb8aae9..b529ba6 100644 --- a/src/htan/pubs.py +++ b/src/htan/pubs.py @@ -2,18 +2,19 @@ Uses NCBI E-utilities REST API — no external dependencies required (stdlib only). -Usage as library: +Usage as library:: + from htan.pubs import search, fetch, fulltext articles = search(keyword="spatial transcriptomics", max_results=10) details = fetch("12345678") -Usage as CLI: +Usage as CLI:: + htan pubs search --keyword "spatial transcriptomics" htan pubs fetch 12345678 htan pubs fulltext "tumor microenvironment" """ -import argparse import json import sys import time @@ -22,6 +23,8 @@ import urllib.request import xml.etree.ElementTree as ET +import click + EUTILS_BASE = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils" TOOL_NAME = "htan_skill" @@ -329,97 +332,106 @@ def format_article_text(article): # --- CLI --- +_PUBS_EPILOG = """\ +Examples: + + htan pubs search + htan pubs search --keyword "spatial transcriptomics" + htan pubs search --author "Sorger PK" + htan pubs fetch 12345678 + htan pubs fulltext "tumor microenvironment" +""" + + +@click.group(name="pubs", epilog=_PUBS_EPILOG) +def pubs(): + """Search HTAN publications on PubMed and PubMed Central.""" + + +def _print_articles(articles, fmt): + if not articles: + click.echo("No articles found.", err=True) + return + if fmt == "json": + click.echo(json.dumps(articles, indent=2)) + else: + for a in articles: + click.echo(format_article_text(a)) + click.echo() + + +@pubs.command(name="search") +@click.option("--keyword", "-k", help="Filter by keyword") +@click.option("--author", "-a", help="Filter by last author") +@click.option("--year", "-y", help="Filter by publication year") +@click.option("--max-results", "-n", "max_results", type=int, default=100, show_default=True) +@click.option("--format", "-f", "fmt", type=click.Choice(["text", "json"]), default="text") +@click.option("--timeout", type=int, default=DEFAULT_TIMEOUT, show_default=True) +@click.option("--dry-run", "dry_run", is_flag=True, help="Show query URL without executing") +def search_cmd(keyword, author, year, max_results, fmt, timeout, dry_run): + """Search HTAN publications on PubMed.""" + if dry_run: + query = build_search_query(keyword=keyword, author=author, year=year) + params = {"db": "pubmed", "term": query, "retmax": str(max_results), + "retmode": "json", "sort": "pub_date", "tool": TOOL_NAME, "email": TOOL_EMAIL} + url = f"{EUTILS_BASE}/esearch.fcgi?{urllib.parse.urlencode(params)}" + click.echo("Dry run — would request:", err=True) + click.echo(f" URL: {url}", err=True) + return + articles = search(keyword=keyword, author=author, year=year, + max_results=max_results, timeout=timeout) + _print_articles(articles, fmt) + + +@pubs.command(name="fetch") +@click.argument("pmids", nargs=-1, required=True) +@click.option("--format", "-f", "fmt", type=click.Choice(["text", "json"]), default="text") +@click.option("--timeout", type=int, default=DEFAULT_TIMEOUT, show_default=True) +@click.option("--dry-run", "dry_run", is_flag=True) +def fetch_cmd(pmids, fmt, timeout, dry_run): + """Fetch details for specific PMIDs.""" + if dry_run: + click.echo(f"Dry run — would fetch PMIDs: {', '.join(pmids)}", err=True) + return + articles = fetch(list(pmids), timeout=timeout) + _print_articles(articles, fmt) + + +@pubs.command(name="fulltext") +@click.argument("query") +@click.option("--max-results", "-n", "max_results", type=int, default=50, show_default=True) +@click.option("--format", "-f", "fmt", type=click.Choice(["text", "json"]), default="text") +@click.option("--timeout", type=int, default=DEFAULT_TIMEOUT, show_default=True) +@click.option("--dry-run", "dry_run", is_flag=True) +def fulltext_cmd(query, max_results, fmt, timeout, dry_run): + """Search HTAN articles in PubMed Central.""" + if dry_run: + grant_query = build_grant_query() + full_query = f"({grant_query}) AND ({query})" + params = {"db": "pmc", "term": full_query, "retmax": str(max_results), + "retmode": "json", "sort": "pub_date", "tool": TOOL_NAME, "email": TOOL_EMAIL} + url = f"{EUTILS_BASE}/esearch.fcgi?{urllib.parse.urlencode(params)}" + click.echo("Dry run — would request:", err=True) + click.echo(f" URL: {url}", err=True) + return + articles = fulltext(query, max_results=max_results, timeout=timeout) + if not articles: + click.echo("No PMC articles found.", err=True) + return + if fmt == "json": + click.echo(json.dumps(articles, indent=2)) + else: + for a in articles: + click.echo(format_article_text(a)) + click.echo() + + def cli_main(argv=None): - """CLI entry point for publication search.""" - parser = argparse.ArgumentParser( - description="Search HTAN publications on PubMed and PubMed Central", - epilog="Examples:\n" - " htan pubs search\n" - ' htan pubs search --keyword "spatial transcriptomics"\n' - ' htan pubs search --author "Sorger PK"\n' - " htan pubs fetch 12345678\n" - ' htan pubs fulltext "tumor microenvironment"\n', - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - subparsers = parser.add_subparsers(dest="command", required=True) - - sp_search = subparsers.add_parser("search", help="Search HTAN publications on PubMed") - sp_search.add_argument("--keyword", "-k", help="Filter by keyword") - sp_search.add_argument("--author", "-a", help="Filter by last author") - sp_search.add_argument("--year", "-y", help="Filter by publication year") - sp_search.add_argument("--max-results", "-n", type=int, default=100, help="Maximum results (default: 100)") - sp_search.add_argument("--format", "-f", choices=["text", "json"], default="text", help="Output format") - sp_search.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT, help=f"HTTP timeout (default: {DEFAULT_TIMEOUT})") - sp_search.add_argument("--dry-run", action="store_true", help="Show query URL without executing") - - sp_fetch = subparsers.add_parser("fetch", help="Fetch details for specific PMIDs") - sp_fetch.add_argument("pmids", nargs="+", help="PubMed IDs to fetch") - sp_fetch.add_argument("--format", "-f", choices=["text", "json"], default="text", help="Output format") - sp_fetch.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT) - sp_fetch.add_argument("--dry-run", action="store_true") - - sp_full = subparsers.add_parser("fulltext", help="Search HTAN articles in PubMed Central") - sp_full.add_argument("query", help="Full-text search query") - sp_full.add_argument("--max-results", "-n", type=int, default=50, help="Maximum results (default: 50)") - sp_full.add_argument("--format", "-f", choices=["text", "json"], default="text", help="Output format") - sp_full.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT) - sp_full.add_argument("--dry-run", action="store_true") - - args = parser.parse_args(argv) - - if args.command == "search": - query = build_search_query(keyword=args.keyword, author=args.author, year=args.year) - if args.dry_run: - params = {"db": "pubmed", "term": query, "retmax": str(args.max_results), - "retmode": "json", "sort": "pub_date", "tool": TOOL_NAME, "email": TOOL_EMAIL} - url = f"{EUTILS_BASE}/esearch.fcgi?{urllib.parse.urlencode(params)}" - print(f"Dry run — would request:", file=sys.stderr) - print(f" URL: {url}", file=sys.stderr) - return - articles = search(keyword=args.keyword, author=args.author, year=args.year, - max_results=args.max_results, timeout=args.timeout) - if not articles: - print("No articles found.", file=sys.stderr) - return - if args.format == "json": - print(json.dumps(articles, indent=2)) - else: - for a in articles: - print(format_article_text(a)) - print() - - elif args.command == "fetch": - if args.dry_run: - print(f"Dry run — would fetch PMIDs: {', '.join(args.pmids)}", file=sys.stderr) - return - articles = fetch(args.pmids, timeout=args.timeout) - if not articles: - print("No articles found.", file=sys.stderr) - return - if args.format == "json": - print(json.dumps(articles, indent=2)) - else: - for a in articles: - print(format_article_text(a)) - print() - - elif args.command == "fulltext": - if args.dry_run: - grant_query = build_grant_query() - full_query = f"({grant_query}) AND ({args.query})" - params = {"db": "pmc", "term": full_query, "retmax": str(args.max_results), - "retmode": "json", "sort": "pub_date", "tool": TOOL_NAME, "email": TOOL_EMAIL} - url = f"{EUTILS_BASE}/esearch.fcgi?{urllib.parse.urlencode(params)}" - print(f"Dry run — would request:", file=sys.stderr) - print(f" URL: {url}", file=sys.stderr) - return - articles = fulltext(args.query, max_results=args.max_results, timeout=args.timeout) - if not articles: - print("No PMC articles found.", file=sys.stderr) - return - if args.format == "json": - print(json.dumps(articles, indent=2)) - else: - for a in articles: - print(format_article_text(a)) - print() + """Backward-compatible entry point — invokes the Click :data:`pubs` group.""" + try: + return pubs.main(args=argv, prog_name="htan pubs", standalone_mode=False) + except click.exceptions.Exit as e: + sys.exit(e.exit_code) + except click.exceptions.ClickException as e: + e.show() + sys.exit(e.exit_code) diff --git a/src/htan/query/bq.py b/src/htan/query/bq.py index f5fab70..78b526f 100644 --- a/src/htan/query/bq.py +++ b/src/htan/query/bq.py @@ -1,27 +1,29 @@ """Query HTAN metadata in ISB-CGC BigQuery. Supports direct SQL, table listing, and schema inspection. -Requires: pip install htan[bigquery] -Usage as library: +Usage as library:: + from htan.query.bq import BigQueryClient client = BigQueryClient() tables = client.list_tables() schema = client.describe_table("clinical_tier1_demographics") -Usage as CLI: +Usage as CLI:: + htan query bq tables htan query bq describe clinical_tier1_demographics htan query bq sql "SELECT COUNT(*) FROM ..." """ -import argparse import csv import io import json import re import sys +import click + HTAN_DATASET = "isb-cgc-bq.HTAN" HTAN_DATASET_VERSIONED = "isb-cgc-bq.HTAN_versioned" @@ -202,128 +204,145 @@ def describe_table(self, table, versioned=False): # --- CLI --- -def cli_main(argv=None): - """CLI entry point for BigQuery queries.""" - parser = argparse.ArgumentParser( - description="Query HTAN metadata in ISB-CGC BigQuery", - epilog="Examples:\n" - " htan query bq tables\n" - " htan query bq describe clinical_tier1_demographics\n" - ' htan query bq sql "SELECT COUNT(*) FROM `isb-cgc-bq.HTAN.clinical_tier1_demographics_current`"\n' - ' htan query bq query "How many breast cancer patients?"\n', - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - subparsers = parser.add_subparsers(dest="command", required=True) - - sp_query = subparsers.add_parser("query", help="Natural language query (outputs context for Claude)") - sp_query.add_argument("question", help="Natural language question") - sp_query.add_argument("--project", "-p", help="Google Cloud project ID") - sp_query.add_argument("--format", "-f", choices=["text", "json", "csv"], default="text") - - sp_sql = subparsers.add_parser("sql", help="Execute a direct SQL query") - sp_sql.add_argument("sql", help="SQL query") - sp_sql.add_argument("--project", "-p", help="Google Cloud project ID") - sp_sql.add_argument("--format", "-f", choices=["text", "json", "csv"], default="text") - sp_sql.add_argument("--dry-run", action="store_true") - - sp_tables = subparsers.add_parser("tables", help="List available HTAN tables") - sp_tables.add_argument("--project", "-p", help="Google Cloud project ID") - sp_tables.add_argument("--dry-run", action="store_true") - sp_tables.add_argument("--versioned", action="store_true") - - sp_desc = subparsers.add_parser("describe", help="Describe table schema") - sp_desc.add_argument("table_name", help="Table name") - sp_desc.add_argument("--project", "-p", help="Google Cloud project ID") - sp_desc.add_argument("--dry-run", action="store_true") - sp_desc.add_argument("--versioned", action="store_true") - - args = parser.parse_args(argv) - - if args.command == "query": - # Output NL query context for Claude - print("=== HTAN BigQuery Natural Language Query ===") - print() - print("USER QUESTION:", args.question) - print() - print(TABLE_SCHEMAS_SUMMARY) - print() - print("=== INSTRUCTIONS ===") - print("Generate a safe read-only SQL query against isb-cgc-bq.HTAN tables.") - print("Then execute with: htan query bq sql \"YOUR_SQL_HERE\"") - return +_BQ_EPILOG = """\ +Examples: + + htan query bq tables + htan query bq describe clinical_tier1_demographics + htan query bq sql "SELECT COUNT(*) FROM `isb-cgc-bq.HTAN.clinical_tier1_demographics_current`" + htan query bq query "How many breast cancer patients?" +""" + +@click.group(name="bq", epilog=_BQ_EPILOG) +def bq(): + """Query HTAN metadata in ISB-CGC BigQuery.""" + + +@bq.command(name="query") +@click.argument("question") +@click.option("--project", "-p", help="Google Cloud project ID") +@click.option("--format", "-f", "fmt", type=click.Choice(["text", "json", "csv"]), default="text") +def query_cmd(question, project, fmt): + """Output natural-language query context for an LLM (does not execute).""" + click.echo("=== HTAN BigQuery Natural Language Query ===") + click.echo() + click.echo(f"USER QUESTION: {question}") + click.echo() + click.echo(TABLE_SCHEMAS_SUMMARY) + click.echo() + click.echo("=== INSTRUCTIONS ===") + click.echo("Generate a safe read-only SQL query against isb-cgc-bq.HTAN tables.") + click.echo('Then execute with: htan query bq sql "YOUR_SQL_HERE"') + + +def _bq_client_or_exit(project): try: - client = BigQueryClient(project=getattr(args, "project", None)) + return BigQueryClient(project=project) except BigQueryError as e: - print(f"Error: {e}", file=sys.stderr) - sys.exit(1) - - if args.command == "sql": - try: - if args.dry_run: - result = client.query(args.sql, dry_run=True) - bytes_est = result["bytes_processed"] - if bytes_est > 1_000_000_000: - cost_str = f"{bytes_est / 1_000_000_000:.2f} GB" - elif bytes_est > 1_000_000: - cost_str = f"{bytes_est / 1_000_000:.1f} MB" - else: - cost_str = f"{bytes_est:,} bytes" - print(f"Dry run — estimated data processed: {cost_str}", file=sys.stderr) - print(f"SQL:\n{result['sql']}", file=sys.stderr) - return - - df = client.query(args.sql) - if df.empty: - print("Query returned no results.", file=sys.stderr) - return - print(f"Returned {len(df)} rows, {len(df.columns)} columns", file=sys.stderr) - if args.format == "json": - print(df.to_json(orient="records", indent=2)) - elif args.format == "csv": - output = io.StringIO() - df.to_csv(output, index=False, quoting=csv.QUOTE_NONNUMERIC) - print(output.getvalue()) + click.echo(f"Error: {e}", err=True) + raise click.exceptions.Exit(1) + + +@bq.command(name="sql") +@click.argument("sql_query") +@click.option("--project", "-p", help="Google Cloud project ID") +@click.option("--format", "-f", "fmt", type=click.Choice(["text", "json", "csv"]), default="text") +@click.option("--dry-run", "dry_run", is_flag=True) +def sql_cmd(sql_query, project, fmt, dry_run): + """Execute a direct SQL query.""" + client = _bq_client_or_exit(project) + try: + if dry_run: + result = client.query(sql_query, dry_run=True) + bytes_est = result["bytes_processed"] + if bytes_est > 1_000_000_000: + cost_str = f"{bytes_est / 1_000_000_000:.2f} GB" + elif bytes_est > 1_000_000: + cost_str = f"{bytes_est / 1_000_000:.1f} MB" else: - print(df.to_string(index=False)) - except BigQueryError as e: - print(f"Error: {e}", file=sys.stderr) - sys.exit(1) - - elif args.command == "tables": - if args.dry_run: - dataset = HTAN_DATASET_VERSIONED if args.versioned else HTAN_DATASET - print(f"Dry run — would list tables from {dataset}", file=sys.stderr) + cost_str = f"{bytes_est:,} bytes" + click.echo(f"Dry run — estimated data processed: {cost_str}", err=True) + click.echo(f"SQL:\n{result['sql']}", err=True) return - try: - tables = client.list_tables(versioned=args.versioned) - for t in tables: - print(t) - print(f"\n{len(tables)} tables", file=sys.stderr) - except Exception as e: - print(f"Error: {e}", file=sys.stderr) - sys.exit(1) - elif args.command == "describe": - if args.dry_run: - print(f"Dry run — would describe: {args.table_name}", file=sys.stderr) + df = client.query(sql_query) + if df.empty: + click.echo("Query returned no results.", err=True) return - try: - info = client.describe_table(args.table_name, versioned=args.versioned) - print(f"Table: {info['table']}") - print(f"Rows: {info['num_rows']:,}") - print(f"Size: {info['num_bytes']:,} bytes") - if info["description"]: - print(f"Description: {info['description']}") - print() - print(f"{'Column':<40} {'Type':<15} {'Mode':<10} {'Description'}") - print(f"{'-'*40} {'-'*15} {'-'*10} {'-'*30}") - for f in info["schema"]: - desc = f["description"] - if len(desc) > 50: - desc = desc[:47] + "..." - print(f"{f['name']:<40} {f['type']:<15} {f['mode']:<10} {desc}") - print(f"\n{len(info['schema'])} columns", file=sys.stderr) - except BigQueryError as e: - print(f"Error: {e}", file=sys.stderr) - sys.exit(1) + click.echo(f"Returned {len(df)} rows, {len(df.columns)} columns", err=True) + if fmt == "json": + click.echo(df.to_json(orient="records", indent=2)) + elif fmt == "csv": + output = io.StringIO() + df.to_csv(output, index=False, quoting=csv.QUOTE_NONNUMERIC) + click.echo(output.getvalue()) + else: + click.echo(df.to_string(index=False)) + except BigQueryError as e: + click.echo(f"Error: {e}", err=True) + raise click.exceptions.Exit(1) + + +@bq.command(name="tables") +@click.option("--project", "-p", help="Google Cloud project ID") +@click.option("--dry-run", "dry_run", is_flag=True) +@click.option("--versioned", is_flag=True, help="Use the HTAN_versioned dataset") +def tables_cmd(project, dry_run, versioned): + """List available HTAN tables.""" + if dry_run: + dataset = HTAN_DATASET_VERSIONED if versioned else HTAN_DATASET + click.echo(f"Dry run — would list tables from {dataset}", err=True) + return + client = _bq_client_or_exit(project) + try: + rows = client.list_tables(versioned=versioned) + for t in rows: + click.echo(t) + click.echo(f"\n{len(rows)} tables", err=True) + except Exception as e: + click.echo(f"Error: {e}", err=True) + raise click.exceptions.Exit(1) + + +@bq.command(name="describe") +@click.argument("table_name") +@click.option("--project", "-p", help="Google Cloud project ID") +@click.option("--dry-run", "dry_run", is_flag=True) +@click.option("--versioned", is_flag=True, help="Use the HTAN_versioned dataset") +def describe_cmd(table_name, project, dry_run, versioned): + """Describe table schema.""" + if dry_run: + click.echo(f"Dry run — would describe: {table_name}", err=True) + return + client = _bq_client_or_exit(project) + try: + info = client.describe_table(table_name, versioned=versioned) + click.echo(f"Table: {info['table']}") + click.echo(f"Rows: {info['num_rows']:,}") + click.echo(f"Size: {info['num_bytes']:,} bytes") + if info["description"]: + click.echo(f"Description: {info['description']}") + click.echo() + click.echo(f"{'Column':<40} {'Type':<15} {'Mode':<10} {'Description'}") + click.echo(f"{'-'*40} {'-'*15} {'-'*10} {'-'*30}") + for f in info["schema"]: + desc = f["description"] + if len(desc) > 50: + desc = desc[:47] + "..." + click.echo(f"{f['name']:<40} {f['type']:<15} {f['mode']:<10} {desc}") + click.echo(f"\n{len(info['schema'])} columns", err=True) + except BigQueryError as e: + click.echo(f"Error: {e}", err=True) + raise click.exceptions.Exit(1) + + +def cli_main(argv=None): + """Backward-compatible entry point — invokes the Click :data:`bq` group.""" + try: + return bq.main(args=argv, prog_name="htan query bq", standalone_mode=False) + except click.exceptions.Exit as e: + sys.exit(e.exit_code) + except click.exceptions.ClickException as e: + e.show() + sys.exit(e.exit_code) diff --git a/src/htan/query/portal.py b/src/htan/query/portal.py index 2d2a1a7..1022710 100644 --- a/src/htan/query/portal.py +++ b/src/htan/query/portal.py @@ -1,22 +1,23 @@ """Query HTAN data via the HTAN Data Portal's ClickHouse backend. The HTAN data portal (data.humantumoratlas.org) uses a ClickHouse cloud database. -Credentials are loaded via htan.config (3-tier: env > keychain > config file). -Queries via the HTTP interface — zero extra dependencies (stdlib only). +Credentials are loaded via :mod:`htan.config` (3-tier: env > keychain > config file). +Queries go through the HTTP interface — zero extra dependencies (stdlib only). + +Usage as library:: -Usage as library: from htan.query.portal import PortalClient client = PortalClient() files = client.find_files(organ="Breast", assay="scRNA-seq", limit=10) rows = client.query("SELECT count() FROM files") -Usage as CLI: +Usage as CLI:: + htan query portal tables htan query portal files --organ Breast --limit 5 htan query portal sql "SELECT atlas_name, COUNT(*) as n FROM files GROUP BY atlas_name" """ -import argparse import base64 import csv import io @@ -28,6 +29,9 @@ import urllib.error import urllib.parse import urllib.request +from types import SimpleNamespace + +import click from htan.config import ( ConfigError, @@ -550,121 +554,197 @@ def _clinical_query(self, table, atlas=None, organ=None, limit=DEFAULT_LIMIT): # --- CLI --- -def cli_main(argv=None): - """CLI entry point for portal queries.""" - parser = argparse.ArgumentParser( - description="Query HTAN data via the portal ClickHouse backend", - epilog="Examples:\n" - " htan query portal tables\n" - " htan query portal describe files\n" - ' htan query portal files --organ Breast --assay "scRNA-seq" --limit 5\n' - " htan query portal files --data-file-id HTA9_1_19512 --output json\n" - ' htan query portal sql "SELECT atlas_name, COUNT(*) as n FROM files GROUP BY atlas_name"\n' - " htan query portal manifest HTA9_1_19512 --output-dir /tmp/manifests\n", - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - subparsers = parser.add_subparsers(dest="command", required=True) - - def add_common_args(sp, include_limit=True): - if include_limit: - sp.add_argument("--limit", "-l", type=int, default=DEFAULT_LIMIT, help=f"Row limit (default: {DEFAULT_LIMIT})") - sp.add_argument("--output", "-o", choices=["text", "json", "csv"], default="text", help="Output format") - sp.add_argument("--dry-run", action="store_true", help="Show SQL without executing") - sp.add_argument("--database", "-d", help="Database name (default: auto-discover)") - - # files - sp_files = subparsers.add_parser("files", help="Query files with filters") - sp_files.add_argument("--organ", help="Filter by organ type") - sp_files.add_argument("--assay", help="Filter by assay name") - sp_files.add_argument("--atlas", help="Filter by atlas name") - sp_files.add_argument("--level", help="Filter by data level") - sp_files.add_argument("--file-format", help="Filter by file format") - sp_files.add_argument("--filename", help="Filter by filename (substring)") - sp_files.add_argument("--data-file-id", nargs="+", help="Look up specific HTAN_Data_File_ID(s)") - add_common_args(sp_files) - - # demographics - sp_demo = subparsers.add_parser("demographics", help="Query patient demographics") - sp_demo.add_argument("--atlas", help="Filter by atlas name") - sp_demo.add_argument("--gender", help="Filter by gender") - sp_demo.add_argument("--race", help="Filter by race") - add_common_args(sp_demo) - - # diagnosis - sp_diag = subparsers.add_parser("diagnosis", help="Query diagnosis information") - sp_diag.add_argument("--atlas", help="Filter by atlas name") - sp_diag.add_argument("--organ", help="Filter by tissue/organ of origin") - sp_diag.add_argument("--primary-diagnosis", help="Filter by primary diagnosis") - add_common_args(sp_diag) - - # cases - sp_cases = subparsers.add_parser("cases", help="Query merged cases") - sp_cases.add_argument("--atlas", help="Filter by atlas name") - sp_cases.add_argument("--organ", help="Filter by tissue/organ of origin") - add_common_args(sp_cases) - - # specimen - sp_spec = subparsers.add_parser("specimen", help="Query biospecimen metadata") - sp_spec.add_argument("--atlas", help="Filter by atlas name") - sp_spec.add_argument("--preservation", help="Filter by preservation method") - sp_spec.add_argument("--tissue-type", help="Filter by tumor tissue type") - add_common_args(sp_spec) - - # summary - sp_summary = subparsers.add_parser("summary", help="Show HTAN data summary") - sp_summary.add_argument("--output", "-o", choices=["text", "json"], default="text", help="Output format") - sp_summary.add_argument("--dry-run", action="store_true", help="Show what would be queried") - sp_summary.add_argument("--database", "-d", help="Database name") - - # sql - sp_sql = subparsers.add_parser("sql", help="Execute a direct read-only SQL query") - sp_sql.add_argument("sql", help="SQL query to execute") - sp_sql.add_argument("--limit", "-l", type=int, default=SQL_DEFAULT_LIMIT, help=f"Row limit (default: {SQL_DEFAULT_LIMIT})") - sp_sql.add_argument("--no-limit", action="store_true", help="Skip auto-applying LIMIT") - sp_sql.add_argument("--output", "-o", choices=["text", "json", "csv"], default="text", help="Output format") - sp_sql.add_argument("--dry-run", action="store_true", help="Show SQL without executing") - sp_sql.add_argument("--database", "-d", help="Database name") - - # tables - sp_tables = subparsers.add_parser("tables", help="List available tables") - sp_tables.add_argument("--dry-run", action="store_true", help="Show what would be queried") - sp_tables.add_argument("--database", "-d", help="Database name") - - # describe - sp_desc = subparsers.add_parser("describe", help="Describe table schema") - sp_desc.add_argument("table_name", help="Table name") - sp_desc.add_argument("--dry-run", action="store_true", help="Show what would be queried") - sp_desc.add_argument("--database", "-d", help="Database name") - - # manifest - sp_manifest = subparsers.add_parser("manifest", help="Generate download manifests from file IDs") - sp_manifest.add_argument("ids", nargs="*", help="HTAN_Data_File_IDs") - sp_manifest.add_argument("--file", "-f", help="File containing IDs (one per line)") - sp_manifest.add_argument("--output-dir", default=".", help="Directory for manifest files") - sp_manifest.add_argument("--dry-run", action="store_true", help="Show SQL without executing") - sp_manifest.add_argument("--database", "-d", help="Database name") - - args = parser.parse_args(argv) - - # Dispatch to handler functions - handlers = { - "files": _cmd_files, "demographics": _cmd_demographics, - "diagnosis": _cmd_diagnosis, "cases": _cmd_cases, - "specimen": _cmd_specimen, "summary": _cmd_summary, - "sql": _cmd_sql, "tables": _cmd_tables, - "describe": _cmd_describe, "manifest": _cmd_manifest, - } - +def _run(handler, args): + """Run a portal command handler with consistent error handling.""" try: - handlers[args.command](args) + handler(args) except PortalError as e: - print(f"Error: {e}", file=sys.stderr) + click.echo(f"Error: {e}", err=True) for hint in e.hints: - print(f"Hint: {hint}", file=sys.stderr) - sys.exit(1) + click.echo(f"Hint: {hint}", err=True) + raise click.exceptions.Exit(1) except ValueError as e: - print(f"Error: {e}", file=sys.stderr) - sys.exit(1) + click.echo(f"Error: {e}", err=True) + raise click.exceptions.Exit(1) + + +_PORTAL_EPILOG = """\ +Examples: + + htan query portal tables + htan query portal describe files + htan query portal files --organ Breast --assay "scRNA-seq" --limit 5 + htan query portal files --data-file-id HTA9_1_19512 --output json + htan query portal sql "SELECT atlas_name, COUNT(*) as n FROM files GROUP BY atlas_name" + htan query portal manifest HTA9_1_19512 --output-dir /tmp/manifests +""" + + +@click.group(epilog=_PORTAL_EPILOG) +def portal(): + """Query HTAN data via the portal ClickHouse backend.""" + + +@portal.command() +@click.option("--organ", help="Filter by organ type") +@click.option("--assay", help="Filter by assay name") +@click.option("--atlas", help="Filter by atlas name") +@click.option("--level", help="Filter by data level") +@click.option("--file-format", "file_format", help="Filter by file format") +@click.option("--filename", help="Filter by filename (substring)") +@click.option("--data-file-id", "data_file_id", multiple=True, + help="Look up specific HTAN_Data_File_ID(s)") +@click.option("--limit", "-l", type=int, default=DEFAULT_LIMIT, show_default=True, + help="Row limit") +@click.option("--output", "-o", type=click.Choice(["text", "json", "csv"]), + default="text", show_default=True, help="Output format") +@click.option("--dry-run", "dry_run", is_flag=True, help="Show SQL without executing") +@click.option("--database", "-d", help="Database name (default: auto-discover)") +def files(organ, assay, atlas, level, file_format, filename, data_file_id, + limit, output, dry_run, database): + """Query files with filters.""" + args = SimpleNamespace( + organ=organ, assay=assay, atlas=atlas, level=level, + file_format=file_format, filename=filename, + data_file_id=list(data_file_id) if data_file_id else None, + limit=limit, output=output, dry_run=dry_run, database=database, + ) + _run(_cmd_files, args) + + +@portal.command() +@click.option("--atlas", help="Filter by atlas name") +@click.option("--gender", help="Filter by gender") +@click.option("--race", help="Filter by race") +@click.option("--limit", "-l", type=int, default=DEFAULT_LIMIT, show_default=True) +@click.option("--output", "-o", type=click.Choice(["text", "json", "csv"]), default="text") +@click.option("--dry-run", "dry_run", is_flag=True) +@click.option("--database", "-d") +def demographics(atlas, gender, race, limit, output, dry_run, database): + """Query patient demographics.""" + args = SimpleNamespace(atlas=atlas, gender=gender, race=race, + limit=limit, output=output, dry_run=dry_run, database=database) + _run(_cmd_demographics, args) + + +@portal.command() +@click.option("--atlas", help="Filter by atlas name") +@click.option("--organ", help="Filter by tissue/organ of origin") +@click.option("--primary-diagnosis", "primary_diagnosis", help="Filter by primary diagnosis") +@click.option("--limit", "-l", type=int, default=DEFAULT_LIMIT, show_default=True) +@click.option("--output", "-o", type=click.Choice(["text", "json", "csv"]), default="text") +@click.option("--dry-run", "dry_run", is_flag=True) +@click.option("--database", "-d") +def diagnosis(atlas, organ, primary_diagnosis, limit, output, dry_run, database): + """Query diagnosis information.""" + args = SimpleNamespace(atlas=atlas, organ=organ, primary_diagnosis=primary_diagnosis, + limit=limit, output=output, dry_run=dry_run, database=database) + _run(_cmd_diagnosis, args) + + +@portal.command() +@click.option("--atlas", help="Filter by atlas name") +@click.option("--organ", help="Filter by tissue/organ of origin") +@click.option("--limit", "-l", type=int, default=DEFAULT_LIMIT, show_default=True) +@click.option("--output", "-o", type=click.Choice(["text", "json", "csv"]), default="text") +@click.option("--dry-run", "dry_run", is_flag=True) +@click.option("--database", "-d") +def cases(atlas, organ, limit, output, dry_run, database): + """Query merged cases.""" + args = SimpleNamespace(atlas=atlas, organ=organ, limit=limit, output=output, + dry_run=dry_run, database=database) + _run(_cmd_cases, args) + + +@portal.command() +@click.option("--atlas", help="Filter by atlas name") +@click.option("--preservation", help="Filter by preservation method") +@click.option("--tissue-type", "tissue_type", help="Filter by tumor tissue type") +@click.option("--limit", "-l", type=int, default=DEFAULT_LIMIT, show_default=True) +@click.option("--output", "-o", type=click.Choice(["text", "json", "csv"]), default="text") +@click.option("--dry-run", "dry_run", is_flag=True) +@click.option("--database", "-d") +def specimen(atlas, preservation, tissue_type, limit, output, dry_run, database): + """Query biospecimen metadata.""" + args = SimpleNamespace(atlas=atlas, preservation=preservation, tissue_type=tissue_type, + limit=limit, output=output, dry_run=dry_run, database=database) + _run(_cmd_specimen, args) + + +@portal.command() +@click.option("--output", "-o", type=click.Choice(["text", "json"]), default="text") +@click.option("--dry-run", "dry_run", is_flag=True) +@click.option("--database", "-d") +def summary(output, dry_run, database): + """Show HTAN data summary.""" + args = SimpleNamespace(output=output, dry_run=dry_run, database=database) + _run(_cmd_summary, args) + + +@portal.command(name="sql") +@click.argument("sql_query") +@click.option("--limit", "-l", type=int, default=SQL_DEFAULT_LIMIT, show_default=True, + help="Row limit") +@click.option("--no-limit", "no_limit", is_flag=True, help="Skip auto-applying LIMIT") +@click.option("--output", "-o", type=click.Choice(["text", "json", "csv"]), default="text") +@click.option("--dry-run", "dry_run", is_flag=True) +@click.option("--database", "-d") +def sql_cmd(sql_query, limit, no_limit, output, dry_run, database): + """Execute a direct read-only SQL query.""" + args = SimpleNamespace(sql=sql_query, limit=limit, no_limit=no_limit, + output=output, dry_run=dry_run, database=database) + _run(_cmd_sql, args) + + +@portal.command() +@click.option("--dry-run", "dry_run", is_flag=True) +@click.option("--database", "-d") +def tables(dry_run, database): + """List available tables.""" + args = SimpleNamespace(dry_run=dry_run, database=database) + _run(_cmd_tables, args) + + +@portal.command() +@click.argument("table_name") +@click.option("--dry-run", "dry_run", is_flag=True) +@click.option("--database", "-d") +def describe(table_name, dry_run, database): + """Describe table schema.""" + args = SimpleNamespace(table_name=table_name, dry_run=dry_run, database=database) + _run(_cmd_describe, args) + + +@portal.command() +@click.argument("ids", nargs=-1) +@click.option("--file", "-f", "file", help="File containing IDs (one per line)") +@click.option("--output-dir", "output_dir", default=".", show_default=True, + help="Directory for manifest files") +@click.option("--dry-run", "dry_run", is_flag=True) +@click.option("--database", "-d") +def manifest(ids, file, output_dir, dry_run, database): + """Generate download manifests from file IDs.""" + args = SimpleNamespace(ids=list(ids), file=file, output_dir=output_dir, + dry_run=dry_run, database=database) + _run(_cmd_manifest, args) + + +def cli_main(argv=None): + """Backward-compatible entry point — invokes the Click :data:`portal` group. + + Args: + argv: List of CLI arguments (e.g., ``["tables"]``). If ``None``, uses ``sys.argv``. + + Returns ``None`` on success; raises :class:`SystemExit` on errors or when a + Click action (such as ``--help``) requests an exit. + """ + try: + return portal.main(args=argv, prog_name="htan query portal", standalone_mode=False) + except click.exceptions.Exit as e: + sys.exit(e.exit_code) + except click.exceptions.ClickException as e: + e.show() + sys.exit(e.exit_code) # --- CLI handler functions (mirror original cmd_* functions) --- diff --git a/tests/test_cli.py b/tests/test_cli.py index 854f9c5..b320533 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -40,7 +40,8 @@ def test_cli_no_args(): capture_output=True, text=True, timeout=10, ) assert result.returncode != 0 - assert "Usage:" in result.stdout + # Click prints "missing command" usage to stderr + assert "Usage:" in (result.stdout + result.stderr) def test_cli_query_no_backend(): diff --git a/tests/test_cli_dispatch.py b/tests/test_cli_dispatch.py index 5265447..a471221 100644 --- a/tests/test_cli_dispatch.py +++ b/tests/test_cli_dispatch.py @@ -1,142 +1,140 @@ -"""Tests for htan.cli — command routing / dispatch logic.""" +"""Tests for htan.cli — Click command tree composition. -import sys -from unittest.mock import patch, MagicMock +After the migration to Click, dispatch is handled by Click's group/subcommand +mechanism rather than by hand-rolled ``_dispatch_*`` helpers. We verify here +that subcommands resolve to the expected groups and produce sensible help/usage +output for invalid inputs. +""" -import pytest +from click.testing import CliRunner -from htan.cli import main, _dispatch_query, _dispatch_download, _dispatch_config +from htan.cli import cli + + +def _run(args): + runner = CliRunner() + return runner.invoke(cli, args) # =========================================================================== -# _dispatch_query +# Top-level structure # =========================================================================== -def test_dispatch_query_portal(): - with patch("htan.query.portal.cli_main") as mock_cli: - _dispatch_query(["portal", "tables"]) - mock_cli.assert_called_once_with(["tables"]) +def test_cli_help(): + result = _run(["--help"]) + assert result.exit_code == 0 + assert "Usage:" in result.output + for cmd in ("query", "download", "pubs", "model", "files", "init", "config"): + assert cmd in result.output -def test_dispatch_query_bq(): - with patch("htan.query.bq.cli_main") as mock_cli: - _dispatch_query(["bq", "tables"]) - mock_cli.assert_called_once_with(["tables"]) +def test_cli_version(): + result = _run(["--version"]) + assert result.exit_code == 0 + assert "htan" in result.output -def test_dispatch_query_no_args(): - with pytest.raises(SystemExit): - _dispatch_query([]) +def test_cli_unknown_command(): + result = _run(["definitely-not-a-real-command"]) + assert result.exit_code != 0 -def test_dispatch_query_unknown_backend(): - with pytest.raises(SystemExit): - _dispatch_query(["unknown_backend"]) +def test_cli_no_args_shows_usage(): + result = _run([]) + # Click groups exit 2 when no subcommand is supplied; usage goes to stdout. + assert result.exit_code == 2 + assert "Usage:" in result.output # =========================================================================== -# _dispatch_download +# Subgroup composition # =========================================================================== -def test_dispatch_download_synapse(): - with patch("htan.download.synapse.cli_main") as mock_cli: - _dispatch_download(["synapse", "syn12345678"]) - mock_cli.assert_called_once_with(["syn12345678"]) +def test_query_group_lists_backends(): + result = _run(["query", "--help"]) + assert result.exit_code == 0 + assert "portal" in result.output + assert "bq" in result.output -def test_dispatch_download_gen3(): - with patch("htan.download.gen3.cli_main") as mock_cli: - _dispatch_download(["gen3", "download", "drs://dg.4DFC/abc"]) - mock_cli.assert_called_once_with(["download", "drs://dg.4DFC/abc"]) +def test_query_no_backend(): + result = _run(["query"]) + assert result.exit_code == 2 -def test_dispatch_download_no_args(): - with pytest.raises(SystemExit): - _dispatch_download([]) +def test_query_unknown_backend(): + result = _run(["query", "unknown-backend"]) + assert result.exit_code != 0 -def test_dispatch_download_unknown_backend(): - with pytest.raises(SystemExit): - _dispatch_download(["ftp"]) +def test_download_group_lists_backends(): + result = _run(["download", "--help"]) + assert result.exit_code == 0 + assert "synapse" in result.output + assert "gen3" in result.output -# =========================================================================== -# _dispatch_config -# =========================================================================== +def test_download_no_backend(): + result = _run(["download"]) + assert result.exit_code == 2 -def test_dispatch_config_check(capsys): - with patch("htan.config.check_setup", return_value={"portal": "configured"}): - _dispatch_config(["check"]) - out = capsys.readouterr().out - assert '"ok": true' in out +def test_download_unknown_backend(): + result = _run(["download", "ftp"]) + assert result.exit_code != 0 -def test_dispatch_config_help(capsys): - _dispatch_config(["--help"]) - out = capsys.readouterr().out - assert "Usage:" in out +# =========================================================================== +# config +# =========================================================================== -def test_dispatch_config_init_portal(): - with patch("htan.init.cli_main") as mock_init: - _dispatch_config(["init-portal"]) - mock_init.assert_called_once_with(["portal"]) +def test_config_check_emits_json(): + result = _run(["config", "check"]) + assert result.exit_code == 0 + assert '"ok": true' in result.output -def test_dispatch_config_unknown(): - with pytest.raises(SystemExit): - _dispatch_config(["unknown_cmd"]) +def test_config_help(): + result = _run(["config", "--help"]) + assert result.exit_code == 0 + assert "Usage:" in result.output -def test_dispatch_config_no_args(capsys): - """No args defaults to 'check'.""" - with patch("htan.config.check_setup", return_value={"portal": "none"}): - _dispatch_config([]) - out = capsys.readouterr().out - assert '"ok": true' in out +def test_config_unknown_subcommand(): + result = _run(["config", "definitely-not-a-real-subcommand"]) + assert result.exit_code != 0 # =========================================================================== -# main() routing +# Top-level subcommands resolve # =========================================================================== -def test_main_routes_pubs(): - with patch("htan.pubs.cli_main") as mock_cli, \ - patch.object(sys, "argv", ["htan", "pubs", "search"]): - main() - mock_cli.assert_called_once_with(["search"]) - - -def test_main_routes_model(): - with patch("htan.model.cli_main") as mock_cli, \ - patch.object(sys, "argv", ["htan", "model", "components"]): - main() - mock_cli.assert_called_once_with(["components"]) +def test_pubs_resolves(): + result = _run(["pubs", "--help"]) + assert result.exit_code == 0 + assert "search" in result.output -def test_main_routes_files(): - with patch("htan.files.cli_main") as mock_cli, \ - patch.object(sys, "argv", ["htan", "files", "stats"]): - main() - mock_cli.assert_called_once_with(["stats"]) +def test_model_resolves(): + result = _run(["model", "--help"]) + assert result.exit_code == 0 + assert "components" in result.output -def test_main_routes_init(): - with patch("htan.init.cli_main") as mock_cli, \ - patch.object(sys, "argv", ["htan", "init"]): - main() - mock_cli.assert_called_once_with([]) +def test_files_resolves(): + result = _run(["files", "--help"]) + assert result.exit_code == 0 + assert "lookup" in result.output -def test_main_routes_query(): - with patch("htan.query.portal.cli_main") as mock_cli, \ - patch.object(sys, "argv", ["htan", "query", "portal", "tables"]): - main() - mock_cli.assert_called_once_with(["tables"]) +def test_init_resolves(): + result = _run(["init", "--help"]) + assert result.exit_code == 0 + assert "Usage:" in result.output -def test_main_routes_download(): - with patch("htan.download.synapse.cli_main") as mock_cli, \ - patch.object(sys, "argv", ["htan", "download", "synapse", "syn123"]): - main() - mock_cli.assert_called_once_with(["syn123"]) +def test_query_portal_resolves(): + result = _run(["query", "portal", "--help"]) + assert result.exit_code == 0 + assert "tables" in result.output + assert "describe" in result.output diff --git a/tests/test_download_logic.py b/tests/test_download_logic.py index eee0f94..d60fa44 100644 --- a/tests/test_download_logic.py +++ b/tests/test_download_logic.py @@ -109,8 +109,9 @@ def test_gen3_cli_resolve_dry_run(): # synapse CLI — help # =========================================================================== -def test_synapse_cli_help(): +def test_synapse_cli_help(capsys): + """cli_main(['--help']) prints usage and returns cleanly under Click.""" from htan.download.synapse import cli_main - with pytest.raises(SystemExit) as exc_info: - cli_main(["--help"]) - assert exc_info.value.code == 0 + cli_main(["--help"]) + captured = capsys.readouterr() + assert "Usage:" in captured.out diff --git a/tests/test_init.py b/tests/test_init.py index bc9a71c..ea38d78 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -331,11 +331,11 @@ def test_cli_main_status(capsys): def test_cli_main_help(capsys): - """cli_main --help prints usage.""" + """cli_main --help prints usage and returns cleanly under Click.""" from htan.init import cli_main - with pytest.raises(SystemExit) as exc_info: - cli_main(["--help"]) - assert exc_info.value.code == 0 + cli_main(["--help"]) + captured = capsys.readouterr() + assert "Usage:" in captured.out # --------------------------------------------------------------------------- diff --git a/uv.lock b/uv.lock index dcc6230..f403ea9 100644 --- a/uv.lock +++ b/uv.lock @@ -9,6 +9,18 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] +[[package]] +name = "accessible-pygments" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c1/bbac6a50d02774f91572938964c582fff4270eee73ab822a4aeea4d8b11b/accessible_pygments-0.0.5.tar.gz", hash = "sha256:40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872", size = 1377899, upload-time = "2024-05-10T11:23:10.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7", size = 1395903, upload-time = "2024-05-10T11:23:08.421Z" }, +] + [[package]] name = "aiofiles" version = "25.1.0" @@ -160,6 +172,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] +[[package]] +name = "alabaster" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, +] + [[package]] name = "anyio" version = "4.12.1" @@ -222,6 +243,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, ] +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + [[package]] name = "backoff" version = "1.11.1" @@ -231,6 +261,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/dd/88df7d5b2077825d6757a674123062c6e7545cc61556b42739e8757b7b65/backoff-1.11.1-py2.py3-none-any.whl", hash = "sha256:61928f8fa48d52e4faa81875eecf308eccfb1016b018bb6bd21e05b5d90a96c5", size = 13141, upload-time = "2021-07-14T13:56:13.096Z" }, ] +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, +] + [[package]] name = "cdislogging" version = "1.1.1" @@ -555,6 +598,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/44/c1d42fe148d4c682b7de9ae06a5ad3793934ebaca0f054493f1615500ddf/dictionaryutils-3.5.1-py3-none-any.whl", hash = "sha256:a6d9b5cd81e3b29a087cb5deca8fef788a46a1e576a82b240bafd78cba37e8fb", size = 16738, upload-time = "2025-07-10T15:13:04.823Z" }, ] +[[package]] +name = "docutils" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, +] + [[package]] name = "drsclient" version = "0.3.2" @@ -750,6 +802,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] +[[package]] +name = "furo" +version = "2025.12.19" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "accessible-pygments" }, + { name = "beautifulsoup4" }, + { name = "pygments" }, + { name = "sphinx" }, + { name = "sphinx-basic-ng" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/20/5f5ad4da6a5a27c80f2ed2ee9aee3f9e36c66e56e21c00fde467b2f8f88f/furo-2025.12.19.tar.gz", hash = "sha256:188d1f942037d8b37cd3985b955839fea62baa1730087dc29d157677c857e2a7", size = 1661473, upload-time = "2025-12-19T17:34:40.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/b2/50e9b292b5cac13e9e81272c7171301abc753a60460d21505b606e15cf21/furo-2025.12.19-py3-none-any.whl", hash = "sha256:bb0ead5309f9500130665a26bee87693c41ce4dbdff864dbfb6b0dae4673d24f", size = 339262, upload-time = "2025-12-19T17:34:38.905Z" }, +] + [[package]] name = "gen3" version = "4.27.5" @@ -1038,6 +1106,7 @@ name = "htan" version = "0.2.0" source = { editable = "." } dependencies = [ + { name = "click" }, { name = "db-dtypes" }, { name = "gen3" }, { name = "google-cloud-bigquery" }, @@ -1051,19 +1120,30 @@ dev = [ { name = "pytest" }, { name = "ruff" }, ] +docs = [ + { name = "furo" }, + { name = "myst-parser" }, + { name = "sphinx" }, + { name = "sphinx-click" }, +] [package.metadata] requires-dist = [ + { name = "click", specifier = ">=8.1" }, { name = "db-dtypes", specifier = ">=1.5" }, + { name = "furo", marker = "extra == 'docs'", specifier = ">=2024.5" }, { name = "gen3", specifier = ">=4.27" }, { name = "google-cloud-bigquery", specifier = ">=3.40" }, { name = "google-cloud-bigquery-storage", specifier = ">=2.36" }, + { name = "myst-parser", marker = "extra == 'docs'", specifier = ">=3.0" }, { name = "pandas", specifier = ">=2.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1" }, + { name = "sphinx", marker = "extra == 'docs'", specifier = ">=7.3" }, + { name = "sphinx-click", marker = "extra == 'docs'", specifier = ">=6.0" }, { name = "synapseclient", specifier = ">=4.0" }, ] -provides-extras = ["dev"] +provides-extras = ["dev", "docs"] [[package]] name = "httpcore" @@ -1114,6 +1194,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "imagesize" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, +] + [[package]] name = "importlib-metadata" version = "8.7.1" @@ -1144,6 +1233,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + [[package]] name = "jsonschema" version = "4.23.0" @@ -1171,6 +1272,103 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + [[package]] name = "marshmallow" version = "3.26.2" @@ -1195,6 +1393,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/59/ef3a3dc499be447098d4a89399beb869f813fee1b5a57d5d79dee2c1bf51/marshmallow_enum-1.5.1-py2.py3-none-any.whl", hash = "sha256:57161ab3dbfde4f57adeb12090f39592e992b9c86d206d02f6bd03ebec60f072", size = 4186, upload-time = "2019-08-21T01:07:44.814Z" }, ] +[[package]] +name = "mdit-py-plugins" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "multidict" version = "6.7.1" @@ -1342,6 +1561,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "myst-parser" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "jinja2" }, + { name = "markdown-it-py" }, + { name = "mdit-py-plugins" }, + { name = "pyyaml" }, + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/a5/9626ba4f73555b3735ad86247a8077d4603aa8628537687c839ab08bfe44/myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4", size = 93985, upload-time = "2025-02-12T10:53:03.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/df/76d0321c3797b54b60fef9ec3bd6f4cfd124b9e422182156a1dd418722cf/myst_parser-4.0.1-py3-none-any.whl", hash = "sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d", size = 84579, upload-time = "2025-02-12T10:53:02.078Z" }, +] + [[package]] name = "nest-asyncio" version = "1.6.0" @@ -2260,6 +2496,132 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "snowballstemmer" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, +] + +[[package]] +name = "sphinx" +version = "8.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125, upload-time = "2024-10-13T20:27:10.448Z" }, +] + +[[package]] +name = "sphinx-basic-ng" +version = "1.0.0b2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/0b/a866924ded68efec7a1759587a4e478aec7559d8165fac8b2ad1c0e774d6/sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9", size = 20736, upload-time = "2023-07-08T18:40:54.166Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/dd/018ce05c532a22007ac58d4f45232514cd9d6dd0ee1dc374e309db830983/sphinx_basic_ng-1.0.0b2-py3-none-any.whl", hash = "sha256:eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b", size = 22496, upload-time = "2023-07-08T18:40:52.659Z" }, +] + +[[package]] +name = "sphinx-click" +version = "6.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "docutils" }, + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/ed/a9767cd1b8b7fbdf260a89d5c8c86e20e3536b9878579e5ab7965a291e55/sphinx_click-6.2.0.tar.gz", hash = "sha256:fc78b4154a4e5159462e36de55b8643747da6cda86b3b52a8bb62289e603776c", size = 27035, upload-time = "2025-12-04T19:33:05.437Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/bd/cb244695f67f77b0a36200ce1670fc42a6fe2770847e870daab99cc2b177/sphinx_click-6.2.0-py3-none-any.whl", hash = "sha256:1fb1851cb4f2c286d43cbcd57f55db6ef5a8d208bfc3370f19adde232e5803d7", size = 8939, upload-time = "2025-12-04T19:33:04.037Z" }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + [[package]] name = "synapseclient" version = "4.11.0"