|
| 1 | +#!/usr/bin/env python3 |
| 2 | +import argparse |
| 3 | +import logging |
| 4 | +import sys |
| 5 | +from typing import Dict, List, Tuple |
| 6 | +import numpy as np |
| 7 | +import re |
| 8 | +import requests |
| 9 | + |
| 10 | +FLOWBIO_USERNAME = "username" |
| 11 | +FLOWBIO_PASSWORD = "password" |
| 12 | + |
| 13 | +# ------------------------- |
| 14 | +# CLI Usage - python3 ./flowrunTEanalysis.py --pid ######### --filter sample_name 'STAU2_HepG2.*$' -n #ofbatches --start-batch 1 --end-batch 10 |
| 15 | +# ------------------------- |
| 16 | +def parse_args(): |
| 17 | + p = argparse.ArgumentParser(description="Run Flow.bio TE analysis using flowbio library (fetch + client-side filter by sample name)") |
| 18 | + p.add_argument("--pid", "--PID", dest="project_id", required=True, |
| 19 | + help="Flow.bio Project ID (string)") |
| 20 | + p.add_argument("--filter", nargs=2, metavar=("KEY", "VALUE"), action="append", default=None, |
| 21 | + help='Metadata filter. Supported: --filter sample_name "<regex>", --filter experimental_method "<text>"') |
| 22 | + p.add_argument("-n", "--num-chunks", type=int, default=1, |
| 23 | + help="Split selected samples into N executions using numpy.array_split (default: 1)") |
| 24 | + p.add_argument("--start-batch", type=int, default=1, |
| 25 | + help="Start execution from batch number (1-based, default: 1)") |
| 26 | + p.add_argument("--end-batch", type=int, default=None, |
| 27 | + help="End execution at batch number (1-based, default: all batches)") |
| 28 | + p.add_argument("--limit", type=str, default=None, |
| 29 | + help="Limit samples after filtering. Use 'N' for first N samples, or 'M-N' for range (e.g., '15-37'). Default: no limit") |
| 30 | + return p.parse_args() |
| 31 | + |
| 32 | +# ------------------------- |
| 33 | +# Logging |
| 34 | +# ------------------------- |
| 35 | +def setup_logging(): |
| 36 | + pass |
| 37 | + |
| 38 | +# ------------------------- |
| 39 | +# CLIP pipeline settings |
| 40 | +# ------------------------- |
| 41 | +PIPELINE_CLIP = { |
| 42 | + "prep_execution_id": "583150835173081055", |
| 43 | + "pipeline_id": "860300013917014252", |
| 44 | + "pipeline_name": "hanalysis-clipseq", |
| 45 | + "pipeline_version": "test", |
| 46 | + "nextflow_version": "25.10.4", |
| 47 | +} |
| 48 | + |
| 49 | +# ------------------------- |
| 50 | +# Data parameters (file IDs) |
| 51 | +# ------------------------- |
| 52 | +DATA_PARAMS = { |
| 53 | + "gtf": "865333367351887680", |
| 54 | + "fasta": "320468299270948664", |
| 55 | + "seg_gtf": "657490829677407079", |
| 56 | + "fasta_fai": "416915275728809491", |
| 57 | + "regions_gtf": "322316217358102943", |
| 58 | + "filtered_gtf": "166527636003294230", |
| 59 | + "genome_index": "626326653018779728", |
| 60 | + "regions_filt_gtf": "607452590642180296", |
| 61 | + "ncrna_chrom_sizes": "754069987244417321", |
| 62 | + "genome_chrom_sizes": "655637113164580324", |
| 63 | + "ncrna_genome_index": "660565400267158951", |
| 64 | + "regions_resolved_gtf": "484888182031316412", |
| 65 | + "representative_transcript": "166527636003294230", |
| 66 | + "representative_transcript_fai": "530453389518046265", |
| 67 | + "representative_transcript_gtf": "166527636003294230", |
| 68 | + "ncrna_fasta": "472564573697657643", |
| 69 | + "ncrna_fasta_fai": "896151603580010259", |
| 70 | + "telescope_gtf": "396036320917779302", |
| 71 | + "tetranscripts_gtf": "607147647259993520", |
| 72 | +} |
| 73 | + |
| 74 | +# ------------------------- |
| 75 | +# REST helpers for data discovery (prep execution and samples) |
| 76 | +# ------------------------- |
| 77 | +API_BASE = "https://api.flow.bio" |
| 78 | + |
| 79 | +def rest_login(session: requests.Session) -> str: |
| 80 | + r = session.post(f"{API_BASE}/login", json={"username": FLOWBIO_USERNAME, "password": FLOWBIO_PASSWORD}, timeout=30) |
| 81 | + r.raise_for_status() |
| 82 | + return r.json()["token"] |
| 83 | + |
| 84 | +def resolve_pipeline_version_id(session: requests.Session, token: str, pipeline_id: str, version_label: str) -> str: |
| 85 | + headers = {"Authorization": f"Bearer {token}"} |
| 86 | + r = session.get(f"{API_BASE}/pipelines/{pipeline_id}", headers=headers, timeout=30) |
| 87 | + r.raise_for_status() |
| 88 | + pipeline = r.json() |
| 89 | + return next((v["id"] for v in pipeline.get("versions", []) if v.get("name") == version_label), None) |
| 90 | + |
| 91 | +def fetch_prep_execution(session: requests.Session, token: str, prep_execution_id: str) -> Dict: |
| 92 | + headers = {"Authorization": f"Bearer {token}"} |
| 93 | + r = session.get(f"{API_BASE}/executions/{prep_execution_id}", headers=headers, timeout=30) |
| 94 | + r.raise_for_status() |
| 95 | + return r.json() |
| 96 | + |
| 97 | +def fetch_all_project_samples(session: requests.Session, token: str, project_id: str, page_size: int = 100, fetch_details: bool = False) -> List[Dict]: |
| 98 | + headers = {"Authorization": f"Bearer {token}"} |
| 99 | + page = 1 |
| 100 | + collected: List[Dict] = [] |
| 101 | + while True: |
| 102 | + r = session.get( |
| 103 | + f"{API_BASE}/projects/{project_id}/samples", |
| 104 | + params={"page": page, "count": page_size}, |
| 105 | + headers=headers, |
| 106 | + timeout=30, |
| 107 | + ) |
| 108 | + r.raise_for_status() |
| 109 | + payload = r.json() |
| 110 | + samples = payload.get("samples", []) |
| 111 | + if not samples: |
| 112 | + break |
| 113 | + |
| 114 | + # If fetch_details is True, get full sample details including metadata |
| 115 | + if fetch_details: |
| 116 | + for sample in samples: |
| 117 | + sample_id = sample.get("id") |
| 118 | + if sample_id: |
| 119 | + detail_r = session.get( |
| 120 | + f"{API_BASE}/samples/{sample_id}", |
| 121 | + headers=headers, |
| 122 | + timeout=30, |
| 123 | + ) |
| 124 | + detail_r.raise_for_status() |
| 125 | + detail_data = detail_r.json() |
| 126 | + # Merge detail data into sample |
| 127 | + sample.update(detail_data) |
| 128 | + |
| 129 | + collected.extend(samples) |
| 130 | + if len(samples) < page_size: |
| 131 | + break |
| 132 | + page += 1 |
| 133 | + return collected |
| 134 | + |
| 135 | +# ------------------------- |
| 136 | +# Limit parsing |
| 137 | +# ------------------------- |
| 138 | +def parse_limit(limit_str: str | None) -> Tuple[int | None, int | None]: |
| 139 | + """ |
| 140 | + Parse limit string into start and end indices (0-based). |
| 141 | + Returns (start, end) where end is exclusive. |
| 142 | + Examples: |
| 143 | + - "14" -> (0, 14) # first 14 samples |
| 144 | + - "15-37" -> (14, 37) # samples 15-37 (1-based becomes 0-based) |
| 145 | + """ |
| 146 | + if not limit_str: |
| 147 | + return None, None |
| 148 | + |
| 149 | + if '-' in limit_str: |
| 150 | + # Range format: M-N |
| 151 | + try: |
| 152 | + start_str, end_str = limit_str.split('-', 1) |
| 153 | + start = int(start_str.strip()) - 1 # Convert to 0-based |
| 154 | + end = int(end_str.strip()) # Keep as 1-based for end |
| 155 | + if start < 0 or end <= start: |
| 156 | + raise ValueError("Invalid range: start must be >= 1 and end must be > start") |
| 157 | + return start, end |
| 158 | + except ValueError as e: |
| 159 | + raise SystemExit(f"Invalid range format '{limit_str}': {e}. Use format like '15-37'") |
| 160 | + else: |
| 161 | + # Single number format: N (first N samples) |
| 162 | + try: |
| 163 | + count = int(limit_str.strip()) |
| 164 | + if count <= 0: |
| 165 | + raise ValueError("Count must be > 0") |
| 166 | + return 0, count |
| 167 | + except ValueError as e: |
| 168 | + raise SystemExit(f"Invalid limit format '{limit_str}': {e}. Use format like '14' or '15-37'") |
| 169 | + |
| 170 | +# ------------------------- |
| 171 | +# Client-side filters |
| 172 | +# ------------------------- |
| 173 | +def filter_by_sample_name(samples: List[Dict], regex_expr: str | None) -> List[Dict]: |
| 174 | + if not regex_expr: |
| 175 | + return samples |
| 176 | + try: |
| 177 | + pattern = re.compile(regex_expr) |
| 178 | + except re.error as e: |
| 179 | + raise SystemExit(f"Invalid regex for sample_name: {e}") |
| 180 | + matched = [s for s in samples if pattern.search((s.get("name") or ""))] |
| 181 | + return matched |
| 182 | + |
| 183 | +def filter_by_experimental_method(samples: List[Dict], search_text: str | None) -> List[Dict]: |
| 184 | + """Filter samples where experimental method field matches search_text (case-insensitive)""" |
| 185 | + if not search_text: |
| 186 | + return samples |
| 187 | + |
| 188 | + matched = [] |
| 189 | + found_methods = set() # For debugging |
| 190 | + |
| 191 | + for s in samples: |
| 192 | + exp_method = None |
| 193 | + metadata = s.get("metadata", {}) |
| 194 | + if isinstance(metadata, dict): |
| 195 | + exp_obj = ( |
| 196 | + metadata.get("experimentalMethod") |
| 197 | + or metadata.get("experimental_method") |
| 198 | + or metadata.get("Experimental Method") |
| 199 | + ) |
| 200 | + if isinstance(exp_obj, dict): |
| 201 | + exp_method = exp_obj.get("value") |
| 202 | + elif isinstance(exp_obj, str): |
| 203 | + exp_method = exp_obj |
| 204 | + |
| 205 | + if not exp_method: |
| 206 | + exp_method = s.get("experimental_method") or s.get("experimentalMethod") or s.get("Experimental Method") |
| 207 | + |
| 208 | + if exp_method: |
| 209 | + found_methods.add(str(exp_method)) |
| 210 | + if search_text.lower() == str(exp_method).lower(): |
| 211 | + matched.append(s) |
| 212 | + |
| 213 | + # Debug output |
| 214 | + if not matched and found_methods: |
| 215 | + print(f"\nDEBUG: Found experimental methods in samples: {sorted(found_methods)}") |
| 216 | + print(f"DEBUG: Searching for: '{search_text}'") |
| 217 | + elif not found_methods: |
| 218 | + print(f"\nDEBUG: No experimental method found in any sample metadata") |
| 219 | + print(f"DEBUG: Sample metadata keys (first sample): {list(samples[0].keys()) if samples else 'no samples'}") |
| 220 | + if samples and isinstance(samples[0].get("metadata"), dict): |
| 221 | + print(f"DEBUG: Metadata keys (first sample): {list(samples[0].get('metadata', {}).keys())}") |
| 222 | + |
| 223 | + return matched |
| 224 | + |
| 225 | +# ------------------------- |
| 226 | +# Main |
| 227 | +# ------------------------- |
| 228 | +def main(): |
| 229 | + args = parse_args() |
| 230 | + setup_logging() |
| 231 | + |
| 232 | + project_id = args.project_id |
| 233 | + |
| 234 | + # CLIP pipeline settings |
| 235 | + prep_execution_id = PIPELINE_CLIP["prep_execution_id"] |
| 236 | + pipeline_id = PIPELINE_CLIP["pipeline_id"] |
| 237 | + pipeline_name = PIPELINE_CLIP["pipeline_name"] |
| 238 | + pipeline_version = PIPELINE_CLIP["pipeline_version"] |
| 239 | + nextflow_version = PIPELINE_CLIP["nextflow_version"] |
| 240 | + |
| 241 | + # REST session & auth |
| 242 | + session = requests.Session() |
| 243 | + token = rest_login(session) |
| 244 | + headers = {"Authorization": f"Bearer {token}"} |
| 245 | + |
| 246 | + # Resolve pipeline version ID |
| 247 | + version_id = resolve_pipeline_version_id(session, token, pipeline_id, pipeline_version) |
| 248 | + |
| 249 | + # Prep execution & reference files via REST |
| 250 | + ex = fetch_prep_execution(session, token, prep_execution_id) |
| 251 | + fileset_id = ex["fileset"]["id"] |
| 252 | + data_params = DATA_PARAMS.copy() |
| 253 | + |
| 254 | + # Parse --filter to determine if we need full sample details |
| 255 | + needs_details = False |
| 256 | + if args.filter: |
| 257 | + for filter_key, filter_value in args.filter: |
| 258 | + if filter_key.lower() == "experimental_method": |
| 259 | + needs_details = True # This is in full metadata, need to fetch details |
| 260 | + break |
| 261 | + |
| 262 | + # Fetch all samples for the project via REST (with details if needed for filtering) |
| 263 | + project_samples = fetch_all_project_samples(session, token, project_id, page_size=100, fetch_details=needs_details) |
| 264 | + |
| 265 | + # Apply filters |
| 266 | + selected = project_samples |
| 267 | + if args.filter: |
| 268 | + for key, value in args.filter: |
| 269 | + if key.lower() == "sample_name": |
| 270 | + selected = filter_by_sample_name(selected, value) |
| 271 | + elif key.lower() == "experimental_method": |
| 272 | + selected = filter_by_experimental_method(selected, value) |
| 273 | + else: |
| 274 | + raise SystemExit(f"Unsupported filter key: {key}. Supported: sample_name, experimental_method") |
| 275 | + |
| 276 | + # Apply limit if specified |
| 277 | + if args.limit: |
| 278 | + start_idx, end_idx = parse_limit(args.limit) |
| 279 | + original_count = len(selected) |
| 280 | + |
| 281 | + if start_idx is not None and end_idx is not None: |
| 282 | + # Validate range |
| 283 | + if start_idx >= len(selected): |
| 284 | + raise SystemExit(f"Start index {start_idx + 1} exceeds available samples ({len(selected)})") |
| 285 | + if end_idx > len(selected): |
| 286 | + end_idx = len(selected) |
| 287 | + |
| 288 | + selected = selected[start_idx:end_idx] |
| 289 | + |
| 290 | + # Print filtered list |
| 291 | + print(f"\nFiltered {len(selected)} samples:") |
| 292 | + for s in selected: |
| 293 | + print(f" {s['id']}: {s['name']}") |
| 294 | + |
| 295 | + if not selected: |
| 296 | + raise SystemExit("No samples selected after applying filter.") |
| 297 | + |
| 298 | + # Split into N execution batches |
| 299 | + n_chunks = max(1, int(args.num_chunks)) |
| 300 | + chunks = [list(chunk) for chunk in np.array_split(np.array(selected, dtype=object), n_chunks)] |
| 301 | + |
| 302 | + # Determine which batches to execute |
| 303 | + start_batch = max(1, args.start_batch) |
| 304 | + end_batch = args.end_batch if args.end_batch is not None else len(chunks) |
| 305 | + end_batch = min(end_batch, len(chunks)) |
| 306 | + |
| 307 | + if start_batch > len(chunks): |
| 308 | + raise SystemExit(f"Start batch {start_batch} exceeds total batches {len(chunks)}") |
| 309 | + |
| 310 | + # Build and submit one execution per chunk in the specified range |
| 311 | + run_urls = [] |
| 312 | + for i, chunk in enumerate(chunks, start=1): |
| 313 | + # Skip batches outside the specified range |
| 314 | + if i < start_batch or i > end_batch: |
| 315 | + continue |
| 316 | + |
| 317 | + # Build rows in the format expected by the pipeline |
| 318 | + rows = [{ |
| 319 | + "sample": s["id"], |
| 320 | + "values": { |
| 321 | + } |
| 322 | + } for s in chunk] |
| 323 | + |
| 324 | + # Pipeline parameters |
| 325 | + params = { |
| 326 | + "move_umi_to_header": "false", |
| 327 | + #"umi_header_format": "NNNNNNNNNN", |
| 328 | + "umi_separator": "rbc:", |
| 329 | + "skip_umi_dedupe": "false", |
| 330 | + "crosslink_position": "start", |
| 331 | + "encode_eclip": "true", |
| 332 | + "run_te": "true", |
| 333 | + "source": "fastq", |
| 334 | + #"star_params": "--outFilterMultimapNmax 100 --outFilterMultimapScoreRange 1 --outSAMattributes All --alignSJoverhangMin 8 --alignSJDBoverhangMin 1 --outFilterType BySJout --alignIntronMin 20 --alignIntronMax 1000000 --outFilterScoreMin 10 --alignEndsType Extend5pOfRead1 --twopassMode Basic --limitOutSJcollapsed 4000000", |
| 335 | + } |
| 336 | + |
| 337 | + # Build payload for REST API submission |
| 338 | + payload = { |
| 339 | + "params": params, |
| 340 | + "data_params": data_params, |
| 341 | + "csv_params": {"input": {"rows": rows}}, |
| 342 | + "retries": None, |
| 343 | + "nextflow_version": nextflow_version, |
| 344 | + "fileset": fileset_id, |
| 345 | + "resequence_samples": False, |
| 346 | + } |
| 347 | + |
| 348 | + if i == 1: |
| 349 | + proceed = input("Submit? (y/n): ").strip().lower() |
| 350 | + if proceed != "y": |
| 351 | + sys.exit(0) |
| 352 | + |
| 353 | + r = session.post( |
| 354 | + f"{API_BASE}/pipelines/versions/{version_id}/run", |
| 355 | + headers=headers, |
| 356 | + json=payload, |
| 357 | + timeout=120, |
| 358 | + ) |
| 359 | + r.raise_for_status() |
| 360 | + run_id = r.json()["id"] |
| 361 | + |
| 362 | + url = f"https://app.flow.bio/executions/{run_id}" |
| 363 | + run_urls.append(url) |
| 364 | + print(f"Batch {i}: {url}") |
| 365 | + |
| 366 | +if __name__ == "__main__": |
| 367 | + main() |
0 commit comments