cgcardona / muse public
arrange.py python
271 lines 8.6 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
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)