#!/usr/bin/env python3 """ Bioconda package crawler with tiered export (T0 / T1 / T2). Data sources: - https://conda.anaconda.org/bioconda//repodata.json - https://api.anaconda.org/package/bioconda/ """ from __future__ import annotations import argparse import json import time from dataclasses import dataclass from pathlib import Path from typing import Any, Dict, Iterable, List, Optional, Set, Tuple import requests from packaging.version import InvalidVersion, Version REPO_BASE = "https://conda.anaconda.org/bioconda" ANACONDA_PACKAGE_API = "https://api.anaconda.org/package/bioconda/{package_name}" ANACONDA_CHANNEL_PACKAGES_API = "https://api.anaconda.org/packages/bioconda" T1_DOMAIN_KEYWORDS = { "single_cell": [ "single-cell", "single cell", "scrna", "scanpy", "seurat", "cellranger", "scvelo", "scvi", ], "spatial_transcriptomics": [ "spatial", "visium", "stereoseq", "spaceranger", "squidpy", "spatialde", ], "proteomics": [ "proteomics", "proteome", "peptide", "mass spectrometry", "metaproteomics", ], "metabolomics": [ "metabolomics", "metabolome", "metabolic profiling", "lipidomics", "lc-ms", ], } @dataclass class PackageRecord: package_name: str latest_version: str build_number: int depends: List[str] subdir: str timestamp: int repodata_summary: str = "" downloads: int = -1 def _safe_version(raw_version: str) -> Version: try: return Version(raw_version) except InvalidVersion: return Version("0") def fetch_repodata(subdir: str, timeout: int = 60) -> Dict: url = f"{REPO_BASE}/{subdir}/repodata.json" response = requests.get(url, timeout=timeout) response.raise_for_status() return response.json() def iter_repodata_packages(payload: Dict) -> Iterable[Tuple[str, Dict]]: for section in ("packages", "packages.conda"): for filename, meta in payload.get(section, {}).items(): yield filename, meta def build_latest_index(subdirs: List[str]) -> Dict[str, PackageRecord]: latest: Dict[str, PackageRecord] = {} for subdir in subdirs: payload = fetch_repodata(subdir=subdir) for _filename, meta in iter_repodata_packages(payload): pkg_name = meta.get("name") version = meta.get("version", "") build_number = int(meta.get("build_number", 0)) depends = meta.get("depends", []) or [] timestamp = int(meta.get("timestamp", 0)) repodata_summary = meta.get("summary", "") or "" if not pkg_name: continue candidate = PackageRecord( package_name=pkg_name, latest_version=version, build_number=build_number, depends=depends, subdir=subdir, timestamp=timestamp, repodata_summary=repodata_summary, ) if pkg_name not in latest: latest[pkg_name] = candidate continue current = latest[pkg_name] candidate_key = (_safe_version(candidate.latest_version), candidate.build_number, candidate.timestamp) current_key = (_safe_version(current.latest_version), current.build_number, current.timestamp) if candidate_key > current_key: latest[pkg_name] = candidate return latest def _normalize_owners(owners_field) -> List[str]: if not owners_field: return [] owners: List[str] = [] for item in owners_field: if isinstance(item, str): owners.append(item) elif isinstance(item, dict): login = item.get("login") or item.get("name") if login: owners.append(login) return sorted(set(owners)) def fetch_package_profile(package_name: str, timeout: int = 30) -> Dict: url = ANACONDA_PACKAGE_API.format(package_name=package_name) response = requests.get(url, timeout=timeout) if response.status_code == 404: return {} response.raise_for_status() return response.json() def extract_download_count(profile: Dict[str, Any]) -> int: for key in ("ndownloads", "downloads", "download_count"): value = profile.get(key) if isinstance(value, int): return value if isinstance(value, str) and value.isdigit(): return int(value) return -1 def _match_any_keyword(text: str, keywords: List[str]) -> bool: low = text.lower() return any(keyword in low for keyword in keywords) def _decode_channel_packages_payload(payload: Any) -> Dict[str, int]: result: Dict[str, int] = {} if isinstance(payload, list): entries = payload elif isinstance(payload, dict): entries = payload.get("items") or payload.get("packages") or [] else: entries = [] for item in entries: if not isinstance(item, dict): continue pkg_name = item.get("name") if not pkg_name: continue result[pkg_name] = max(result.get(pkg_name, -1), extract_download_count(item)) return result def fetch_download_index_from_channel_api(timeout: int = 60, max_pages: int = 50) -> Dict[str, int]: download_index: Dict[str, int] = {} page = 1 while True: params = {"page": page} response = requests.get(ANACONDA_CHANNEL_PACKAGES_API, params=params, timeout=timeout) response.raise_for_status() payload = response.json() current_page = _decode_channel_packages_payload(payload) if not current_page: if page == 1: current_page = _decode_channel_packages_payload(payload) if not current_page: break for pkg_name, downloads in current_page.items(): download_index[pkg_name] = max(download_index.get(pkg_name, -1), downloads) if isinstance(payload, list): break total_pages = payload.get("total_pages") next_page = payload.get("next_page") if isinstance(total_pages, int) and page >= total_pages: break if next_page is None and not payload.get("items"): break page += 1 if page > max_pages: break return download_index def build_download_index_from_package_api( index: Dict[str, PackageRecord], profile_cache: Dict[str, Dict], timeout: int = 30, sleep_seconds: float = 0.05, max_packages: int = 0, ) -> Dict[str, int]: download_index: Dict[str, int] = {} package_names = sorted(index.keys()) if max_packages > 0: package_names = package_names[:max_packages] for package_name in package_names: profile = profile_cache.get(package_name) if profile is None: profile = fetch_package_profile(package_name=package_name, timeout=timeout) profile_cache[package_name] = profile if sleep_seconds > 0: time.sleep(sleep_seconds) download_index[package_name] = extract_download_count(profile) return download_index def resolve_download_index( index: Dict[str, PackageRecord], download_source: str, profile_cache: Dict[str, Dict], package_api_max: int, ) -> Dict[str, int]: print(f"max packages for package_api mode: {package_api_max}") if download_source == "channel_api": try: print("Fetching download index from channel API...") return fetch_download_index_from_channel_api() except Exception: print("Failed to fetch download index from channel API, falling back to package API...") return build_download_index_from_package_api( index=index, profile_cache=profile_cache, max_packages=package_api_max, ) return build_download_index_from_package_api( index=index, profile_cache=profile_cache, max_packages=package_api_max, ) def _sorted_by_download(items: List[Tuple[str, PackageRecord]]) -> List[Tuple[str, PackageRecord]]: return sorted( items, key=lambda x: (x[1].downloads, _safe_version(x[1].latest_version), x[1].timestamp), reverse=True, ) def select_t0_records( index: Dict[str, PackageRecord], k_per_domain: int, k_overall: int, ) -> List[Tuple[str, PackageRecord, str]]: selected: List[Tuple[str, PackageRecord, str]] = [] selected_names: Set[str] = set() for domain, keywords in T1_DOMAIN_KEYWORDS.items(): domain_candidates: List[Tuple[str, PackageRecord]] = [] for package_name, record in index.items(): searchable = f"{package_name} {record.repodata_summary}" if _match_any_keyword(searchable, keywords): domain_candidates.append((package_name, record)) ranked_domain = _sorted_by_download(domain_candidates) for package_name, record in ranked_domain[:k_per_domain]: if package_name in selected_names: continue selected.append((package_name, record, domain)) selected_names.add(package_name) overall_candidates = _sorted_by_download(list(index.items())) for package_name, record in overall_candidates[:k_overall]: if package_name in selected_names: continue selected.append((package_name, record, "overall")) selected_names.add(package_name) return selected def select_t1_records(index: Dict[str, PackageRecord], max_per_domain: int) -> List[Tuple[str, PackageRecord, str]]: selected: List[Tuple[str, PackageRecord, str]] = [] used: Set[str] = set() for domain, keywords in T1_DOMAIN_KEYWORDS.items(): counter = 0 domain_candidates: List[Tuple[str, PackageRecord]] = [] for package_name, record in index.items(): if package_name in used: continue dep_text = " ".join(record.depends) searchable = f"{package_name} {record.repodata_summary} {dep_text}" if _match_any_keyword(searchable, keywords): domain_candidates.append((package_name, record)) # Prefer higher-download tools first for better practical coverage. ranked_domain = _sorted_by_download(domain_candidates) for package_name, record in ranked_domain[:max_per_domain]: if package_name in used: continue selected.append((package_name, record, domain)) used.add(package_name) counter += 1 if counter >= max_per_domain: break return selected def backfill_t1_records( index: Dict[str, PackageRecord], selected: List[Tuple[str, PackageRecord, str]], target_total: int, ) -> List[Tuple[str, PackageRecord, str]]: if target_total <= 0 or len(selected) >= target_total: return selected used = {pkg for pkg, _record, _domain in selected} ranked_overall = _sorted_by_download(list(index.items())) for package_name, record in ranked_overall: if package_name in used: continue selected.append((package_name, record, "t1_backfill_overall")) used.add(package_name) if len(selected) >= target_total: break return selected def select_t2_records(index: Dict[str, PackageRecord], tools: List[str]) -> List[Tuple[str, PackageRecord]]: selected: List[Tuple[str, PackageRecord]] = [] for tool in tools: normalized = tool.strip() if not normalized: continue lower_map = {name.lower(): name for name in index.keys()} hit = lower_map.get(normalized.lower()) if hit: selected.append((normalized, index[hit])) return selected def enrich_record( software_name: str, record: PackageRecord, tier: str, domain: Optional[str] = None, sleep_seconds: float = 0.2, profile_cache: Optional[Dict[str, Dict]] = None, ) -> Dict: profile_cache = profile_cache or {} profile = profile_cache.get(record.package_name) if profile is None: profile = fetch_package_profile(record.package_name) profile_cache[record.package_name] = profile if sleep_seconds > 0: time.sleep(sleep_seconds) maintainers = _normalize_owners(profile.get("owners")) summary = profile.get("summary") or record.repodata_summary or "" description = profile.get("description") or summary latest_version = profile.get("latest_version") or record.latest_version downloads = record.downloads if record.downloads >= 0 else extract_download_count(profile) return { "tier": tier, "domain": domain or "", "software_name": software_name, "package_name": record.package_name, "latest_version": latest_version, "dependencies": record.depends, "maintainers": maintainers, "description": description, "summary": summary, "downloads": downloads, "license": profile.get("license") or "", "home_url": profile.get("home") or "", "doc_url": profile.get("doc_url") or "", "dev_url": profile.get("dev_url") or "", "source_subdir": record.subdir, "timestamp": record.timestamp, } def export_json(data: List[Dict], output_file: Path) -> None: output_file.parent.mkdir(parents=True, exist_ok=True) with output_file.open("w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) def run_t0( index: Dict[str, PackageRecord], output_dir: Path, k_per_domain: int, k_overall: int, profile_cache: Dict[str, Dict], ) -> List[Dict]: result: List[Dict] = [] for software_name, record, domain in select_t0_records(index, k_per_domain=k_per_domain, k_overall=k_overall): result.append( enrich_record( software_name=software_name, record=record, tier="T0", domain=domain, profile_cache=profile_cache, ) ) export_json(result, output_dir / "bioconda_t0_core_tools.json") return result def run_t1( index: Dict[str, PackageRecord], output_dir: Path, max_per_domain: int, profile_cache: Dict[str, Dict], target_total: int, ) -> List[Dict]: result: List[Dict] = [] selected = select_t1_records(index=index, max_per_domain=max_per_domain) selected = backfill_t1_records(index=index, selected=selected, target_total=target_total) for software_name, record, domain in selected: result.append( enrich_record( software_name=software_name, record=record, tier="T1", domain=domain, profile_cache=profile_cache, ) ) export_json(result, output_dir / "bioconda_t1_domain_tools.json") return result def run_t2( index: Dict[str, PackageRecord], output_dir: Path, t2_tools: List[str], profile_cache: Dict[str, Dict], ) -> List[Dict]: result: List[Dict] = [] selected = select_t2_records(index=index, tools=t2_tools) for software_name, record in selected: result.append( enrich_record( software_name=software_name, record=record, tier="T2", profile_cache=profile_cache, ) ) export_json(result, output_dir / "bioconda_t2_on_demand_tools.json") return result def run_all_package_index(index: Dict[str, PackageRecord], output_dir: Path) -> None: rows = [] for package_name, record in sorted(index.items()): rows.append( { "software_name": package_name, "package_name": package_name, "latest_version": record.latest_version, "dependencies": record.depends, "maintainers": [], "description": record.repodata_summary, "summary": record.repodata_summary, "downloads": record.downloads, "license": "", "home_url": "", "doc_url": "", "dev_url": "", "source_subdir": record.subdir, "timestamp": record.timestamp, } ) export_json(rows, output_dir / "bioconda_all_packages_index.json") def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Crawl Bioconda package metadata in a tiered manner (T0/T1/T2)." ) parser.add_argument( "--tiers", nargs="+", default=["T0", "T1", "T2"], choices=["T0", "T1", "T2", "ALL"], help="Which tiers to run. Use ALL to export whole package index from repodata.", ) parser.add_argument( "--subdirs", nargs="+", default=["linux-64", "noarch"], help="Bioconda subdirs to index, e.g. linux-64 noarch osx-64.", ) parser.add_argument( "--output-dir", default="./output", help="Directory for output JSON files.", ) parser.add_argument( "--t2-tools", nargs="*", default=[], help="Tools for T2 on-demand crawling, e.g. --t2-tools cellranger scanpy", ) parser.add_argument( "--t1-max-per-domain", type=int, default=200, help="Max selected packages per T1 domain.", ) parser.add_argument( "--t1-target-total", type=int, default=600, help="Target total rows for T1. Uses overall backfill when domain matches are insufficient.", ) parser.add_argument( "--downloads-source", choices=["channel_api", "package_api"], default="channel_api", help="Download ranking source. channel_api first, package_api is fallback or explicit mode.", ) parser.add_argument( "--t0-k-per-domain", type=int, default=5, help="T0: top K packages per domain ranked by downloads.", ) parser.add_argument( "--t0-k-overall", type=int, default=20, help="T0: top K packages in overall ranking by downloads.", ) parser.add_argument( "--package-api-max", type=int, default=0, help="Max packages for package_api mode (0 means all indexed packages).", ) return parser.parse_args() def main() -> None: args = parse_args() output_dir = Path(args.output_dir) output_dir.mkdir(parents=True, exist_ok=True) print(f"[1/4] Building Bioconda latest-package index from subdirs: {args.subdirs}") latest_index = build_latest_index(subdirs=args.subdirs) print(f" Indexed packages: {len(latest_index)}") profile_cache: Dict[str, Dict] = {} print("[2/4] Resolving package download counts...") download_index = resolve_download_index( index=latest_index, download_source=args.downloads_source, profile_cache=profile_cache, package_api_max=args.package_api_max, ) for package_name, record in latest_index.items(): record.downloads = download_index.get(package_name, -1) known_downloads = sum(1 for r in latest_index.values() if r.downloads >= 0) print(f" Packages with download count: {known_downloads}") run_all = "ALL" in args.tiers if run_all: print("[3/4] Exporting full package index...") run_all_package_index(index=latest_index, output_dir=output_dir) print(" -> bioconda_all_packages_index.json") if "T0" in args.tiers: print("[4/4] Running T0 core tools (auto top-k by downloads)...") t0_rows = run_t0( index=latest_index, output_dir=output_dir, k_per_domain=args.t0_k_per_domain, k_overall=args.t0_k_overall, profile_cache=profile_cache, ) print(f" -> bioconda_t0_core_tools.json ({len(t0_rows)} rows)") if "T1" in args.tiers: print("[4/4] Running T1 domain tools...") t1_rows = run_t1( index=latest_index, output_dir=output_dir, max_per_domain=args.t1_max_per_domain, profile_cache=profile_cache, target_total=args.t1_target_total, ) print(f" -> bioconda_t1_domain_tools.json ({len(t1_rows)} rows)") if "T2" in args.tiers: print("[4/4] Running T2 on-demand tools...") t2_rows = run_t2( index=latest_index, output_dir=output_dir, t2_tools=args.t2_tools, profile_cache=profile_cache, ) print(f" -> bioconda_t2_on_demand_tools.json ({len(t2_rows)} rows)") print("Done.") if __name__ == "__main__": main()