arrange.py
python
| 1 | """muse arrange [<commit>] — display the arrangement map (instrument activity over sections). |
| 2 | |
| 3 | Shows which instruments are active in which musical sections for a given |
| 4 | commit. The arrangement is derived from the committed snapshot manifest: |
| 5 | files must follow the path convention ``<section>/<instrument>/<filename>`` |
| 6 | (relative to ``muse-work/``). |
| 7 | |
| 8 | Flags |
| 9 | ----- |
| 10 | - ``[COMMIT]`` — target commit (default: HEAD) |
| 11 | - ``--section TEXT`` — show only a specific section's instrumentation |
| 12 | - ``--track TEXT`` — show only a specific instrument's section participation |
| 13 | - ``--compare A B`` — diff two arrangements |
| 14 | - ``--density`` — show byte-size density instead of binary active/inactive |
| 15 | - ``--format text|json|csv`` — output format (default: text) |
| 16 | """ |
| 17 | from __future__ import annotations |
| 18 | |
| 19 | import asyncio |
| 20 | import logging |
| 21 | import pathlib |
| 22 | from typing import Optional |
| 23 | |
| 24 | import typer |
| 25 | from sqlalchemy import select |
| 26 | from sqlalchemy.ext.asyncio import AsyncSession |
| 27 | |
| 28 | from maestro.muse_cli._repo import require_repo |
| 29 | from maestro.muse_cli.db import get_commit_snapshot_manifest, open_session |
| 30 | from maestro.muse_cli.errors import ExitCode |
| 31 | from maestro.muse_cli.models import MuseCliCommit, MuseCliObject |
| 32 | from maestro.services.muse_arrange import ( |
| 33 | ArrangementMatrix, |
| 34 | build_arrangement_diff, |
| 35 | build_arrangement_matrix, |
| 36 | render_diff_json, |
| 37 | render_diff_text, |
| 38 | render_matrix_csv, |
| 39 | render_matrix_json, |
| 40 | render_matrix_text, |
| 41 | ) |
| 42 | |
| 43 | logger = logging.getLogger(__name__) |
| 44 | |
| 45 | app = typer.Typer() |
| 46 | |
| 47 | _HEX_CHARS = frozenset("0123456789abcdef") |
| 48 | |
| 49 | |
| 50 | def _looks_like_commit_prefix(s: str) -> bool: |
| 51 | """Return True if *s* is a 4-64 char lowercase hex string.""" |
| 52 | lower = s.lower() |
| 53 | return 4 <= len(lower) <= 64 and all(c in _HEX_CHARS for c in lower) |
| 54 | |
| 55 | |
| 56 | async def _resolve_commit_id( |
| 57 | session: AsyncSession, |
| 58 | muse_dir: pathlib.Path, |
| 59 | ref_or_prefix: str, |
| 60 | ) -> str: |
| 61 | """Resolve HEAD, branch name, or commit-ID prefix to a full commit_id.""" |
| 62 | if ref_or_prefix.upper() == "HEAD" or not _looks_like_commit_prefix(ref_or_prefix): |
| 63 | head_ref = (muse_dir / "HEAD").read_text().strip() |
| 64 | |
| 65 | if ref_or_prefix.upper() == "HEAD": |
| 66 | ref_path = muse_dir / pathlib.Path(head_ref) |
| 67 | else: |
| 68 | ref_path = muse_dir / "refs" / "heads" / ref_or_prefix |
| 69 | |
| 70 | if not ref_path.exists(): |
| 71 | typer.echo(f"No commits yet or reference '{ref_or_prefix}' not found.") |
| 72 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 73 | |
| 74 | commit_id = ref_path.read_text().strip() |
| 75 | if not commit_id: |
| 76 | typer.echo(f"Reference '{ref_or_prefix}' has no commits yet.") |
| 77 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 78 | return commit_id |
| 79 | |
| 80 | prefix = ref_or_prefix.lower() |
| 81 | result = await session.execute( |
| 82 | select(MuseCliCommit).where(MuseCliCommit.commit_id.startswith(prefix)) |
| 83 | ) |
| 84 | commits = list(result.scalars().all()) |
| 85 | |
| 86 | if not commits: |
| 87 | typer.echo(f"No commit found matching prefix '{prefix[:8]}'") |
| 88 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 89 | if len(commits) > 1: |
| 90 | typer.echo(f"Ambiguous prefix '{prefix[:8]}' - matches {len(commits)} commits:") |
| 91 | for c in commits: |
| 92 | typer.echo(f" {c.commit_id[:8]} {c.message[:60]}") |
| 93 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 94 | |
| 95 | return commits[0].commit_id |
| 96 | |
| 97 | |
| 98 | async def _load_object_sizes( |
| 99 | session: AsyncSession, |
| 100 | manifest: dict[str, str], |
| 101 | ) -> dict[str, int]: |
| 102 | """Return {object_id: size_bytes} for all objects in *manifest*.""" |
| 103 | object_ids = list(set(manifest.values())) |
| 104 | if not object_ids: |
| 105 | return {} |
| 106 | |
| 107 | result = await session.execute( |
| 108 | select(MuseCliObject).where(MuseCliObject.object_id.in_(object_ids)) |
| 109 | ) |
| 110 | return {obj.object_id: obj.size_bytes for obj in result.scalars().all()} |
| 111 | |
| 112 | |
| 113 | async def _load_matrix( |
| 114 | session: AsyncSession, |
| 115 | muse_dir: pathlib.Path, |
| 116 | ref: str, |
| 117 | density: bool, |
| 118 | ) -> ArrangementMatrix: |
| 119 | """Load a commit manifest and build the arrangement matrix.""" |
| 120 | commit_id = await _resolve_commit_id(session, muse_dir, ref) |
| 121 | manifest = await get_commit_snapshot_manifest(session, commit_id) |
| 122 | |
| 123 | if manifest is None: |
| 124 | typer.echo(f"Could not load snapshot for commit {commit_id[:8]}") |
| 125 | raise typer.Exit(code=ExitCode.INTERNAL_ERROR) |
| 126 | |
| 127 | object_sizes: dict[str, int] | None = None |
| 128 | if density: |
| 129 | object_sizes = await _load_object_sizes(session, manifest) |
| 130 | |
| 131 | return build_arrangement_matrix(commit_id, manifest, object_sizes) |
| 132 | |
| 133 | |
| 134 | async def _arrange_async( |
| 135 | root: pathlib.Path, |
| 136 | session: AsyncSession, |
| 137 | commit: str, |
| 138 | compare_a: str | None, |
| 139 | compare_b: str | None, |
| 140 | section_filter: str | None, |
| 141 | track_filter: str | None, |
| 142 | density: bool, |
| 143 | output_format: str, |
| 144 | ) -> None: |
| 145 | """Core arrange logic - fully injectable for unit tests.""" |
| 146 | muse_dir = root / ".muse" |
| 147 | |
| 148 | if compare_a is not None and compare_b is not None: |
| 149 | matrix_a = await _load_matrix(session, muse_dir, compare_a, density) |
| 150 | matrix_b = await _load_matrix(session, muse_dir, compare_b, density) |
| 151 | diff = build_arrangement_diff(matrix_a, matrix_b) |
| 152 | |
| 153 | if output_format == "json": |
| 154 | typer.echo(render_diff_json(diff)) |
| 155 | else: |
| 156 | typer.echo(render_diff_text(diff)) |
| 157 | return |
| 158 | |
| 159 | matrix = await _load_matrix(session, muse_dir, commit, density) |
| 160 | |
| 161 | if not matrix.sections and not matrix.instruments: |
| 162 | typer.echo( |
| 163 | f"Arrangement Map - commit {matrix.commit_id[:8]}\n\n" |
| 164 | "No section-annotated files found.\n" |
| 165 | "Files must follow the path convention: <section>/<instrument>/<filename>" |
| 166 | ) |
| 167 | return |
| 168 | |
| 169 | if output_format == "json": |
| 170 | typer.echo( |
| 171 | render_matrix_json( |
| 172 | matrix, |
| 173 | density=density, |
| 174 | section_filter=section_filter, |
| 175 | track_filter=track_filter, |
| 176 | ) |
| 177 | ) |
| 178 | elif output_format == "csv": |
| 179 | typer.echo( |
| 180 | render_matrix_csv( |
| 181 | matrix, |
| 182 | density=density, |
| 183 | section_filter=section_filter, |
| 184 | track_filter=track_filter, |
| 185 | ) |
| 186 | ) |
| 187 | else: |
| 188 | typer.echo( |
| 189 | render_matrix_text( |
| 190 | matrix, |
| 191 | density=density, |
| 192 | section_filter=section_filter, |
| 193 | track_filter=track_filter, |
| 194 | ) |
| 195 | ) |
| 196 | |
| 197 | |
| 198 | @app.callback(invoke_without_command=True) |
| 199 | def arrange( |
| 200 | ctx: typer.Context, |
| 201 | commit: str = typer.Argument( |
| 202 | default="HEAD", |
| 203 | help="Commit reference: HEAD, branch name, or commit-ID prefix.", |
| 204 | ), |
| 205 | section: Optional[str] = typer.Option( |
| 206 | None, |
| 207 | "--section", |
| 208 | help="Show only a specific section's instrumentation.", |
| 209 | ), |
| 210 | track: Optional[str] = typer.Option( |
| 211 | None, |
| 212 | "--track", |
| 213 | help="Show only a specific instrument's section participation.", |
| 214 | ), |
| 215 | compare: Optional[list[str]] = typer.Option( |
| 216 | None, |
| 217 | "--compare", |
| 218 | help="Diff two arrangements. Provide --compare twice.", |
| 219 | ), |
| 220 | density: bool = typer.Option( |
| 221 | False, |
| 222 | "--density", |
| 223 | help="Show byte-size density per cell instead of binary active/inactive.", |
| 224 | ), |
| 225 | output_format: str = typer.Option( |
| 226 | "text", |
| 227 | "--format", |
| 228 | help="Output format: text (default), json, or csv.", |
| 229 | ), |
| 230 | ) -> None: |
| 231 | """Display the arrangement map: instrument activity over sections.""" |
| 232 | if output_format not in ("text", "json", "csv"): |
| 233 | typer.echo(f"Unknown format '{output_format}'. Choose: text, json, csv.") |
| 234 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 235 | |
| 236 | compare_a: str | None = None |
| 237 | compare_b: str | None = None |
| 238 | |
| 239 | if compare: |
| 240 | if len(compare) != 2: |
| 241 | typer.echo( |
| 242 | "--compare requires exactly two commit references.\n" |
| 243 | " Use: --compare <commit-a> --compare <commit-b>" |
| 244 | ) |
| 245 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 246 | compare_a, compare_b = compare[0], compare[1] |
| 247 | |
| 248 | root = require_repo() |
| 249 | |
| 250 | async def _run() -> None: |
| 251 | async with open_session() as session: |
| 252 | await _arrange_async( |
| 253 | root=root, |
| 254 | session=session, |
| 255 | commit=commit, |
| 256 | compare_a=compare_a, |
| 257 | compare_b=compare_b, |
| 258 | section_filter=section, |
| 259 | track_filter=track, |
| 260 | density=density, |
| 261 | output_format=output_format, |
| 262 | ) |
| 263 | |
| 264 | try: |
| 265 | asyncio.run(_run()) |
| 266 | except typer.Exit: |
| 267 | raise |
| 268 | except Exception as exc: |
| 269 | typer.echo(f"muse arrange failed: {exc}") |
| 270 | logger.error("muse arrange error: %s", exc, exc_info=True) |
| 271 | raise typer.Exit(code=ExitCode.INTERNAL_ERROR) |