From 0ef5989b0fac2e5b0dd1b3211b30f81b26488c7c Mon Sep 17 00:00:00 2001 From: alsmith151 Date: Mon, 23 Mar 2026 18:12:14 +0000 Subject: [PATCH 01/16] Add rendering and template management for genomic figures - Implemented `render.py` to handle conversion from templates to plottable figures, including `ResolvedTrack` and `RenderPlan` classes. - Created `template.py` to define the structure for genomic figure specifications in YAML format, including track and group specifications. - Enhanced `bed.py` to adjust label positioning for better visibility. - Updated `region.py` to ensure proper string representation of genomic regions. - Modified `pyproject.toml` to include dynamic dependencies and improved package metadata. - Added `requirements.txt` for additional dependencies including `pyyaml` and `rich`. - Introduced CLI integration tests in `test_cli.py` for validating YAML templates and command outputs. - Developed grouping strategy tests in `test_grouping.py` to ensure correct grouping behavior. - Created inference tests in `test_inference.py` to validate track classification and color assignment. - Added rendering tests in `test_render.py` to verify template compilation and group handling. - Implemented template model tests in `test_template.py` for round-trip serialization and YAML output. - Removed obsolete `fix_conflicts.py` script as it is no longer needed. --- plotnado/cli/cli.py | 206 ++------------ plotnado/cli/commands_init.py | 351 ++++++++++++++++++++++++ plotnado/cli/commands_plot.py | 225 +++++++++++++++ plotnado/cli/commands_validate.py | 127 +++++++++ plotnado/cli/grouping.py | 277 +++++++++++++++++++ plotnado/cli/inference.py | 441 ++++++++++++++++++++++++++++++ plotnado/cli/render.py | 210 ++++++++++++++ plotnado/template.py | 227 +++++++++++++++ plotnado/tracks/bed.py | 7 +- plotnado/tracks/region.py | 4 +- pyproject.toml | 6 +- requirements.txt | 4 +- tests/test_cli.py | 122 +++++++++ tests/test_grouping.py | 120 ++++++++ tests/test_inference.py | 129 +++++++++ tests/test_render.py | 94 +++++++ tests/test_template.py | 102 +++++++ tools/fix_conflicts.py | 62 ----- 18 files changed, 2459 insertions(+), 255 deletions(-) create mode 100644 plotnado/cli/commands_init.py create mode 100644 plotnado/cli/commands_plot.py create mode 100644 plotnado/cli/commands_validate.py create mode 100644 plotnado/cli/grouping.py create mode 100644 plotnado/cli/inference.py create mode 100644 plotnado/cli/render.py create mode 100644 plotnado/template.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_grouping.py create mode 100644 tests/test_inference.py create mode 100644 tests/test_render.py create mode 100644 tests/test_template.py delete mode 100644 tools/fix_conflicts.py diff --git a/plotnado/cli/cli.py b/plotnado/cli/cli.py index 2be71fe..2995f11 100644 --- a/plotnado/cli/cli.py +++ b/plotnado/cli/cli.py @@ -1,197 +1,29 @@ """ Plotnado command-line interface. -""" -import json -import pathlib -from enum import Enum +Primary workflow: + 1. plotnado init --output template.yaml + Generates a template from track files using inference heuristics + + 2. Edit template.yaml as needed (optional) + + 3. plotnado plot template.yaml --region chr:start-end [--output out.png] + Renders the template for the specified region + + 4. plotnado validate template.yaml + Validates and explains the template +""" import typer -from typing_extensions import Annotated - -import plotnado as pn - - -class OutputFormat(str, Enum): - """Supported output formats.""" - - png = "png" - svg = "svg" - pdf = "pdf" - jpg = "jpg" - jpeg = "jpeg" - tiff = "tiff" - - -app = typer.Typer(help="Plotnado - Simple genomic track visualization") - - -def _markdown_cell(value: object) -> str: - return str(value).replace("|", "\\|") - - -def _emit_options_table(track_alias: str, options: dict[str, dict], section: str | None) -> None: - sections = [section] if section else ["track", "aesthetics", "label"] - typer.echo(f"\n[{track_alias}]") - for section_name in sections: - section_options = options.get(section_name, {}) - typer.echo(f" {section_name}:") - if not section_options: - typer.echo(" (none)") - continue - for field_name, meta in section_options.items(): - choices = meta.get("choices") or [] - choices_text = ",".join(str(choice) for choice in choices) if choices else "—" - typer.echo( - " " - f"{field_name}: type={meta['type']}, default={meta['default']}, " - f"choices={choices_text}, required={meta['required']}" - ) - - -@app.command("track-options") -def track_options( - track: Annotated[ - str | None, - typer.Argument( - help="Track alias to inspect (e.g. bigwig, genes, axis). Omit to list aliases." - ), - ] = None, - all_tracks: Annotated[ - bool, - typer.Option("--all", help="Show options for all track aliases"), - ] = False, - output_format: Annotated[ - str, - typer.Option("--output-format", "-f", help="Output format: table, markdown, or json"), - ] = "table", - section: Annotated[ - str | None, - typer.Option("--section", help="Optional section filter: track, aesthetics, or label"), - ] = None, -): - """Inspect available kwargs for each track alias. - - Examples: - plotnado track-options - plotnado track-options bigwig - plotnado track-options bigwig --section aesthetics - plotnado track-options bigwig -f markdown - plotnado track-options --all -f json - """ - aliases = pn.GenomicFigure.available_track_aliases() - valid_sections = {"track", "aesthetics", "label"} - valid_formats = {"table", "markdown", "json"} - - if output_format not in valid_formats: - raise typer.BadParameter( - f"--output-format must be one of: {', '.join(sorted(valid_formats))}" - ) - if section is not None and section not in valid_sections: - raise typer.BadParameter(f"--section must be one of: {', '.join(sorted(valid_sections))}") - if track and all_tracks: - raise typer.BadParameter("Use either a track alias argument or --all, not both") - - if track is None and not all_tracks: - typer.echo("Available track aliases:") - for alias, class_name in sorted(aliases.items()): - typer.echo(f" {alias:18} -> {class_name}") - typer.echo("\nUse `plotnado track-options ` for full option details.") - return - - requested_aliases = [track] if track else sorted(aliases.keys()) - normalized_aliases = [alias.lower() for alias in requested_aliases] - unknown_aliases = [alias for alias in normalized_aliases if alias not in aliases] - if unknown_aliases: - raise typer.BadParameter( - f"Unknown alias(es): {', '.join(unknown_aliases)}. " - f"Available: {', '.join(sorted(aliases.keys()))}" - ) - - if output_format == "json": - payload = { - alias: pn.GenomicFigure.track_options(alias) - for alias in normalized_aliases - } - if section: - payload = { - alias: {section: data.get(section, {})} - for alias, data in payload.items() - } - typer.echo(json.dumps(payload, indent=2)) - return - - if output_format == "markdown": - for index, alias in enumerate(normalized_aliases): - if section: - options = pn.GenomicFigure.track_options(alias) - typer.echo(f"## {alias}\n") - typer.echo(f"### {section.title()} fields\n") - typer.echo("| Name | Type | Default | Choices | Required | Description |") - typer.echo("|---|---|---|---|---|---|") - for field_name, meta in options.get(section, {}).items(): - choices = meta.get("choices") or [] - choices_text = _markdown_cell( - ", ".join(str(choice) for choice in choices) if choices else "—" - ) - type_cell = _markdown_cell(meta["type"]) - default_cell = _markdown_cell(meta["default"]) - required_cell = _markdown_cell(meta["required"]) - description_cell = _markdown_cell(meta.get("description") or "—") - typer.echo( - f"| {field_name} | {type_cell} | {default_cell} | {choices_text} | {required_cell} | {description_cell} |" - ) - else: - typer.echo(pn.GenomicFigure.track_options_markdown(alias)) - if index != len(normalized_aliases) - 1: - typer.echo("\n") - return - - for alias in normalized_aliases: - _emit_options_table(alias, pn.GenomicFigure.track_options(alias), section) - - -@app.command() -def plot( - coordinates: Annotated[ - str, - typer.Argument(help="Coordinates to plot in format: CHR:START-END"), - ], - output: Annotated[ - pathlib.Path | None, - typer.Option("--output", "-o", help="Output file path"), - ] = None, - output_format: Annotated[ - OutputFormat, - typer.Option("--format", "-f", help="Output format"), - ] = OutputFormat.png, - width: Annotated[ - float, - typer.Option("--width", "-w", help="Figure width in inches"), - ] = 12.0, - dpi: Annotated[ - int, - typer.Option("--dpi", help="Resolution in dots per inch"), - ] = 600, -): - """ - Create a simple genome browser plot. - - Example: - plotnado chr1:1000000-2000000 -o output.png - """ - # Create a basic figure with scale and genes - figure = pn.GenomicFigure(width=width) - figure.add_track("scalebar") - figure.add_track("genes", genome="hg38") - # Determine output path - if output is None: - output = pathlib.Path(f"{coordinates.replace(':', '_')}.{output_format.value}") +# Initialize the main CLI app +app = typer.Typer( + help="PlotNado - Heuristic templates for genomic track visualization", + no_args_is_help=True, +) - # Save the plot - figure.save(output, coordinates, dpi=dpi) - typer.echo(f"Saved plot to {output}") +# Import commands - this registers them with the app +from . import commands_init, commands_plot, commands_validate # noqa: F401 def main(): diff --git a/plotnado/cli/commands_init.py b/plotnado/cli/commands_init.py new file mode 100644 index 0000000..9523eb3 --- /dev/null +++ b/plotnado/cli/commands_init.py @@ -0,0 +1,351 @@ +""" +CLI command: plotnado init + +Generates a template from track files using inference heuristics. +""" + +from pathlib import Path +from typing import Optional + +import typer +from typing_extensions import Annotated +from rich.console import Console +from rich.table import Table + +from plotnado.template import Template, TrackSpec, GuideSpec, GroupSpec, TrackType +from plotnado.cli.inference import infer_track +from plotnado.cli.grouping import ( + PredefinedGroupingStrategies, + apply_grouping_strategy, + detect_and_apply_grouping, +) + +from . import cli + +console = Console() + +# Track type ordering for generated templates: signal first, then peaks, then annotations +_TYPE_ORDER: dict[str, int] = { + TrackType.BIGWIG.value: 0, + TrackType.BEDGRAPH.value: 1, + TrackType.NARROWPEAK.value: 2, + TrackType.BED.value: 3, + TrackType.ANNOTATION.value: 4, + TrackType.LINKS.value: 5, + TrackType.GENE.value: 6, + TrackType.OVERLAY.value: 7, + TrackType.UNKNOWN.value: 8, +} + + +def _sort_tracks(tracks: list[TrackSpec]) -> list[TrackSpec]: + """Sort tracks: BigWig/Bedgraph → NarrowPeak → BED → Links → Unknown.""" + return sorted(tracks, key=lambda t: _TYPE_ORDER.get(str(t.type), 8)) + + +def _show_preview_table(tracks: list[TrackSpec]) -> None: + """Show a rich table preview of inferred tracks.""" + console.print(f"\n[bold cyan]Inferred {len(tracks)} track(s) — preview:[/bold cyan]\n") + table = Table(show_header=True, header_style="bold") + table.add_column("#", style="dim", width=3) + table.add_column("Title") + table.add_column("Type", width=12) + table.add_column("Group") + table.add_column("Color", width=9) + table.add_column("File") + + for i, track in enumerate(tracks, 1): + color_str = track.color or "—" + group_str = track.group or "—" + file_str = Path(track.path).name if track.path else "—" + table.add_row( + str(i), + track.title or "—", + str(track.type), + group_str, + color_str, + file_str, + ) + console.print(table) + + +@cli.app.command("init") +def init_command( + tracks: Annotated[ + list[str], + typer.Argument(help="Path or URL to track files (BigWig, BED, etc.)"), + ], + output: Annotated[ + str, + typer.Option( + "--output", + "-o", + help="Output YAML template file path", + ), + ] = "template.yaml", + genome: Annotated[ + Optional[str], + typer.Option( + "--genome", + "-g", + help="Default genome (e.g., hg38, mm10)", + ), + ] = None, + group_by: Annotated[ + Optional[str], + typer.Option( + "--group-by", + help=( + "Grouping strategy: predefined name (sample, antibody) or regex pattern. " + "Examples: --group-by sample, --group-by '([^_]+)_rep[0-9]'" + ), + ), + ] = None, + auto: Annotated[ + bool, + typer.Option( + "--auto", + help="Generate template automatically without prompting", + ), + ] = False, + no_genes: Annotated[ + bool, + typer.Option( + "--no-genes", + help="Do not include gene track by default", + ), + ] = False, +): + """ + Generate a template from track files using inference heuristics. + + The init command analyzes your track files, infers track types and grouping, + then generates an editable YAML template for rendering plots. + + Supports flexible grouping strategies: + - Predefined: sample, antibody (for seqnado SAMPLE_ANTIBODY.bw patterns) + - Regex: custom patterns like '([^_]+)_rep[0-9]' to group by filename prefix + + Examples: + plotnado init sample1.bw sample2.bw peaks.bed + plotnado init sample1_H3K27ac.bw sample1_H3K4me3.bw sample2_H3K27ac.bw \\ + --group-by sample + plotnado init control_r1.bw control_r2.bw treat_r1.bw treat_r2.bw \\ + --group-by '([^_]+)_r[0-9]' + plotnado init --auto *.bw + """ + + if not tracks: + console.print("[red]Error: At least one track file is required[/red]") + raise typer.Exit(code=1) + + console.print(f"\n[bold]PlotNado Template Generator[/bold]") + console.print(f"Analyzing {len(tracks)} track file(s)...\n") + + # Run inference on all tracks + template = Template() + template.genome = genome + template.guides = GuideSpec( + genes=not no_genes, + axis=True, + scalebar=True, + ) + + inferences = [] + for track_path in tracks: + result = infer_track(track_path) + inferences.append((track_path, result)) + + track_spec = TrackSpec( + path=track_path, + type=result.track_type, + title=result.title, + color=result.suggested_color, + ) + template.tracks.append(track_spec) + + # Detect seqnado pattern + seqnado_results = [infer.is_seqnado for _, infer in inferences] + all_seqnado = all(seqnado_results) + + # Handle grouping + grouping_result = None + + if group_by: + # User provided explicit grouping strategy + try: + strategy = PredefinedGroupingStrategies.parse_group_by(group_by) + if strategy: + grouping_result = apply_grouping_strategy(tracks, strategy) + if not grouping_result: + console.print( + f"[yellow]⚠ Grouping strategy '{group_by}' " + f"did not match any tracks[/yellow]" + ) + else: + console.print(f"[red]Error: Unknown grouping strategy '{group_by}'[/red]") + raise typer.Exit(code=1) + except ValueError as e: + console.print(f"[red]Error: {e}[/red]") + raise typer.Exit(code=1) + + elif not auto: + # Interactive mode + if all_seqnado: + # Seqnado files detected - offer seqnado strategies + console.print("[bold cyan]Seqnado Pipeline Detected[/bold cyan]") + + samples = set() + antibodies = set() + for _, infer in inferences: + if infer.is_seqnado: + samples.add(infer.seqnado_sample) + antibodies.add(infer.seqnado_antibody) + + console.print(f" Samples: {', '.join(sorted(samples))}") + console.print(f" Antibodies: {', '.join(sorted(antibodies))}") + + console.print("\n[bold]How would you like to group tracks?[/bold]") + console.print(" 1. by sample (each antibody for same sample shares scaling)") + console.print(" 2. by antibody (each sample for same antibody shares scaling)") + console.print(" 3. no grouping") + console.print(" 4. custom regex pattern") + + choice = typer.prompt("Select option", type=int, default=1) + + if choice == 1: + strategy = PredefinedGroupingStrategies.get("sample") + grouping_result = apply_grouping_strategy(tracks, strategy) + elif choice == 2: + strategy = PredefinedGroupingStrategies.get("antibody") + grouping_result = apply_grouping_strategy(tracks, strategy) + elif choice == 4: + pattern = typer.prompt( + "Enter regex pattern (e.g., '([^_]+)_rep[0-9]')" + ) + try: + strategy = PredefinedGroupingStrategies.parse_group_by(pattern) + grouping_result = apply_grouping_strategy(tracks, strategy) + except ValueError as e: + console.print(f"[red]Error: {e}[/red]") + raise typer.Exit(code=1) + + else: + # Non-seqnado interactive wizard + # 1. Genome prompt + if not genome: + genome_input = typer.prompt( + "Genome assembly", + default="hg38", + prompt_suffix=" [hg38/mm10/none]: ", + show_default=False, + ) + if genome_input and genome_input.lower() != "none": + template.genome = genome_input + + # 2. Gene track prompt (only if genome set) + if template.genome and not no_genes: + include_genes = typer.confirm( + "Include gene annotation track?", default=True + ) + template.guides.genes = include_genes + else: + template.guides.genes = False + + # 3. Grouping + auto_result = detect_and_apply_grouping(tracks) + if auto_result: + console.print(f"\n[bold cyan]Detected grouping:[/bold cyan] {auto_result.explanation}") + apply = typer.confirm("Apply this grouping?", default=True) + if apply: + grouping_result = auto_result + else: + apply_manual = typer.confirm( + "\nGroup any tracks together for shared autoscaling?", default=False + ) + if apply_manual: + # Show numbered list + console.print("\nTracks:") + for i, track in enumerate(template.tracks, 1): + console.print(f" {i}. {track.title} ({track.type.value})") + + # Collect groups interactively + manual_groups: dict[str, list[int]] = {} + while True: + indices_str = typer.prompt( + "Enter track numbers to group (e.g. 1,3) or leave empty to finish", + default="", + ) + if not indices_str.strip(): + break + try: + indices = [int(x.strip()) - 1 for x in indices_str.split(",")] + if any(i < 0 or i >= len(template.tracks) for i in indices): + console.print("[yellow]⚠ Invalid track numbers, try again[/yellow]") + continue + group_name = typer.prompt("Group name", default=f"group{len(manual_groups) + 1}") + manual_groups[group_name] = indices + except ValueError: + console.print("[yellow]⚠ Enter comma-separated numbers[/yellow]") + + if manual_groups: + from plotnado.cli.grouping import GroupingResult + grouping_result = GroupingResult( + groups=manual_groups, + strategy_name="manual", + explanation=f"Manually grouped {len(manual_groups)} group(s)", + ) + + # 4. BigWig style + bw_tracks = [t for t in template.tracks if str(t.type) in (TrackType.BIGWIG.value, TrackType.BEDGRAPH.value)] + if bw_tracks: + style_input = typer.prompt( + "BigWig display style", + default="fill", + prompt_suffix=" [fill/line]: ", + show_default=False, + ) + if style_input in ("fill", "line"): + for track in bw_tracks: + track.style = style_input + + else: + # Auto mode: auto-detect the best grouping + grouping_result = detect_and_apply_grouping(tracks) + + # Apply grouping result to template + if grouping_result: + console.print(f"\n[green]✓ {grouping_result.explanation}[/green]") + + for group_name, indices in grouping_result.groups.items(): + group_spec = GroupSpec( + name=group_name, + tracks=[template.tracks[i].title for i in indices], + autoscale=True, + autocolor=True, + ) + template.groups.append(group_spec) + + # Set group on individual tracks + for idx in indices: + template.tracks[idx].group = group_name + + for group in template.groups: + console.print(f" {group.name}: {', '.join(str(t) for t in group.tracks)}") + + # Sort tracks: signal first, then peaks, then annotations + template.tracks = _sort_tracks(template.tracks) + + # Show preview table + _show_preview_table(template.tracks) + + # Save template with annotated header + output_path = Path(output) + args_str = " ".join(Path(t).name for t in tracks) + template.save(output_path, header_args=args_str) + + console.print(f"[green]✓ Template saved to:[/green] [bold]{output_path}[/bold]\n") + console.print("[bold]Next steps:[/bold]") + console.print(f" 1. Review: cat {output_path}") + console.print(" 2. Edit as needed (optional)") + console.print(f" 3. Plot: plotnado plot {output_path} --region chr1:1000-2000") diff --git a/plotnado/cli/commands_plot.py b/plotnado/cli/commands_plot.py new file mode 100644 index 0000000..1ee929e --- /dev/null +++ b/plotnado/cli/commands_plot.py @@ -0,0 +1,225 @@ +""" +CLI command: plotnado plot + +Renders a template for specified genomic regions. +""" + +from pathlib import Path +from typing import Optional +import json +import importlib.resources +import pandas as pd + +import typer +from typing_extensions import Annotated +from rich.console import Console + +import plotnado as pn +from plotnado.template import Template +from plotnado.tracks import GenomicRegion +from plotnado.cli.render import TemplateCompiler + +from . import cli + +console = Console() + + +def resolve_gene_region(gene_name: str, genome: Optional[str] = None) -> GenomicRegion: + """ + Resolve a gene name to a genomic region. + + Args: + gene_name: Gene symbol to look up (e.g., 'GNAQ') + genome: Optional genome assembly (e.g., 'hg38', 'mm10') + + Returns: + GenomicRegion corresponding to the gene + + Raises: + ValueError: If gene not found or genome not available + """ + if not genome: + raise ValueError("Cannot resolve gene name without genome specification. Ensure template has genome defined.") + + # Load gene annotations from bundled data + try: + bed_prefix = importlib.resources.files("plotnado.data.gene_bed_files") + with open(bed_prefix / "genes.json") as handle: + mapping = json.load(handle) + + if genome not in mapping: + raise ValueError(f"Gene annotations not available for genome '{genome}'") + + gene_file = bed_prefix / mapping[genome] + genes_df = pd.read_csv(gene_file, sep="\t", header=None) + except Exception as e: + raise ValueError(f"Failed to load gene annotations: {e}") + + # Parse BED format (chrom, start, end, name, ...) + genes_df.columns = [ + "chrom", + "start", + "end", + "name", + *[f"field_{i}" for i in range(max(0, genes_df.shape[1] - 4))], + ] + + # Match gene name (case-insensitive) + match = genes_df.loc[genes_df["name"].astype(str).str.upper() == gene_name.upper()] + + if match.empty: + raise ValueError(f"Gene '{gene_name}' not found in {genome} annotations") + + row = match.iloc[0] + return GenomicRegion( + chromosome=row["chrom"], + start=int(row["start"]), + end=int(row["end"]), + ) + + +@cli.app.command("plot") +def plot_command( + template_file: Annotated[ + str, + typer.Argument(help="Path to YAML template file"), + ], + region: Annotated[ + list[str], + typer.Option( + "--region", + "-r", + help="Genomic region to plot (chr:start-end or gene name). Repeat for multiple regions.", + ), + ], + output: Annotated[ + Optional[str], + typer.Option( + "--output", + "-o", + help="Output image file path. Only valid with a single region.", + ), + ] = None, + format: Annotated[ + str, + typer.Option( + "--format", + "-f", + help="Output format (png, pdf, svg, jpg)", + ), + ] = "png", + width: Annotated[ + float, + typer.Option( + "--width", + "-w", + help="Figure width in inches", + ), + ] = 12.0, + dpi: Annotated[ + int, + typer.Option( + "--dpi", + help="Resolution (dots per inch)", + ), + ] = 600, +): + """ + Render a template for one or more genomic regions. + + The plot command loads a template (created by 'plotnado init'), + applies it to the specified region(s), and saves the resulting plot(s). + + Supports both genomic coordinates and gene names for the region parameter. + + Examples: + plotnado plot template.yaml --region chr1:1000-2000 + plotnado plot template.yaml --region GNAQ + plotnado plot template.yaml -r chr1:1M-2M -r chr2:5M-6M + plotnado plot template.yaml -r chr1:1,000,000-2,000,000 -o plot.pdf + plotnado plot template.yaml --region chr1:start-end --format svg --width 15 + """ + + if not region: + console.print("[red]Error: At least one --region is required[/red]") + raise typer.Exit(code=1) + + if output and len(region) > 1: + console.print("[red]Error: --output can only be used with a single --region[/red]") + raise typer.Exit(code=1) + + # Load template + try: + template = Template.load(template_file) + console.print(f"[green]✓ Loaded template:[/green] {template_file}") + except FileNotFoundError: + console.print(f"[red]Error: Template file not found:[/red] {template_file}") + raise typer.Exit(code=1) + except Exception as e: + console.print(f"[red]Error loading template:[/red] {e}") + raise typer.Exit(code=1) + + # Compile template once (region-independent) + try: + plan = TemplateCompiler.compile(template) + console.print(f"[cyan]Compiled render plan:[/cyan] {len(plan.tracks)} tracks") + except Exception as e: + console.print(f"[red]Error compiling template:[/red] {e}") + raise typer.Exit(code=1) + + for region_str in region: + # Parse region - try gene name first if it doesn't look like a genomic coordinate + gr = None + + if ":" not in region_str: + try: + gr = resolve_gene_region(region_str, genome=template.genome) + console.print(f"[cyan]Resolved gene:[/cyan] {region_str} → {gr}") + except ValueError as e: + console.print(f"[yellow]Could not resolve as gene name:[/yellow] {e}") + console.print("[yellow]Attempting to parse as genomic region...[/yellow]") + + if gr is None: + try: + gr = GenomicRegion.from_str(region_str) + console.print(f"[cyan]Region:[/cyan] {gr}") + except Exception as e: + console.print(f"[red]Error parsing region '{region_str}':[/red] {e}") + console.print("Expected format: chr:start-end (e.g., chr1:1000-2000) or gene name (e.g., GNAQ)") + raise typer.Exit(code=1) + + # Create figure from render plan + try: + fig = pn.GenomicFigure(width=width or plan.width, track_height=plan.track_height) + + if plan.add_scalebar: + fig.scalebar() + if plan.add_axis: + fig.axis() + if plan.add_genes and plan.genome: + fig.genes(plan.genome) + + for resolved_track in plan.tracks: + method, data, kwargs = plan.get_track_by_method(resolved_track.index) + track_method = getattr(fig, method) + if data: + track_method(data, **kwargs) + else: + track_method(**kwargs) + except Exception as e: + console.print(f"[red]Error creating figure for {gr}:[/red] {e}") + raise typer.Exit(code=1) + + # Determine output path + if output: + out_path = Path(output) + else: + safe_region = str(gr).replace(":", "_").replace("-", "_").replace("(", "_").replace(")", "_") + out_path = Path(f"{Path(template_file).stem}_{safe_region}.{format}") + + try: + fig.save(out_path, region=str(gr), dpi=dpi) + console.print(f"[green]✓ Saved plot:[/green] [bold]{out_path}[/bold]") + except Exception as e: + console.print(f"[red]Error saving figure:[/red] {e}") + raise typer.Exit(code=1) diff --git a/plotnado/cli/commands_validate.py b/plotnado/cli/commands_validate.py new file mode 100644 index 0000000..e99ea45 --- /dev/null +++ b/plotnado/cli/commands_validate.py @@ -0,0 +1,127 @@ +""" +CLI command: plotnado validate + +Validates and explains a template. +""" + +from pathlib import Path + +import typer +from typing_extensions import Annotated +from rich.console import Console +from rich.table import Table + +from plotnado.template import Template +from plotnado.cli.render import TemplateCompiler + +from . import cli + +console = Console() + + +@cli.app.command("validate") +def validate_command( + template_file: Annotated[ + str, + typer.Argument(help="Path to YAML template file"), + ], +): + """ + Validate a template and show its configuration. + + Checks for missing files, group reference errors, and other issues + before you attempt to plot. + + Examples: + plotnado validate template.yaml + """ + + # Load template + try: + template = Template.load(template_file) + console.print(f"[green]✓ Template loaded:[/green] {template_file}\n") + except FileNotFoundError: + console.print(f"[red]Error: Template file not found:[/red] {template_file}") + raise typer.Exit(code=1) + except Exception as e: + console.print(f"[red]Error loading template:[/red] {e}") + raise typer.Exit(code=1) + + # Show metadata + console.print("[bold cyan]Metadata[/bold cyan]") + if template.genome: + console.print(f" genome: {template.genome}") + console.print(f" width: {template.width} inches") + console.print(f" track_height: {template.track_height}") + + # Show guides + console.print("\n[bold cyan]Guides[/bold cyan]") + console.print(f" genes: {template.guides.genes}") + console.print(f" axis: {template.guides.axis}") + console.print(f" scalebar: {template.guides.scalebar}") + + # Show tracks + console.print(f"\n[bold cyan]Tracks ({len(template.tracks)})[/bold cyan]") + + table = Table(show_header=True, header_style="bold") + table.add_column("Index", style="dim") + table.add_column("Title") + table.add_column("Type") + table.add_column("Group") + table.add_column("File") + + missing_files: list[str] = [] + + for i, track in enumerate(template.tracks, 1): + group_str = track.group or "—" + file_str = Path(track.path).name if track.path else "—" + + # Check file existence for local paths + if track.path and not track.path.startswith(("http://", "https://", "s3://", "ftp://")): + if not Path(track.path).exists(): + missing_files.append(track.path) + file_str = f"[red]{file_str} ✗[/red]" + + table.add_row( + str(i), + track.title or "—", + str(track.type), + group_str, + file_str, + ) + + console.print(table) + + # Show groups + if template.groups: + console.print(f"\n[bold cyan]Groups ({len(template.groups)})[/bold cyan]") + for group in template.groups: + console.print(f" {group.name}") + if group.tracks: + console.print(f" tracks: {group.tracks}") + console.print(f" autoscale: {group.autoscale}") + console.print(f" autocolor: {group.autocolor}") + + # Report missing files + errors = False + if missing_files: + errors = True + console.print(f"\n[red]✗ {len(missing_files)} file(s) not found:[/red]") + for f in missing_files: + console.print(f" {f}") + + # Dry-compile to catch group reference errors + try: + TemplateCompiler.compile(template) + console.print("\n[green]✓ Group references resolved successfully[/green]") + except ValueError as e: + errors = True + console.print(f"\n[red]✗ Group reference error:[/red] {e}") + console.print("[dim]Tip: Check that track titles in your groups section match track titles exactly (case-insensitive).[/dim]") + + if errors: + console.print("\n[red]Validation failed — fix the issues above before plotting.[/red]") + raise typer.Exit(code=1) + + console.print("\n[green]✓ Validation complete[/green]") + console.print("Next: plotnado plot