cgcardona / muse public
export.py python
244 lines 8.0 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """muse export — export a Muse snapshot to external formats.
2
3 Usage::
4
5 muse export [<commit>] --format midi --output /tmp/song.mid
6 muse export --format json
7 muse export --format musicxml --track piano
8 muse export --format midi --split-tracks
9 muse export --format wav # fails clearly when Storpheus is down
10
11 Flags:
12 <commit> Short commit ID prefix (default: HEAD).
13 --format Target format: midi | json | musicxml | abc | wav.
14 --track Export only files matching this track name substring.
15 --section Export only files matching this section name substring.
16 --output PATH Destination path (default: ./exports/<commit8>.<format>).
17 --split-tracks Write one file per track (MIDI only).
18
19 This command is read-only — it never creates a new commit or modifies the
20 working tree. The same commit + format always produces identical output.
21 """
22 from __future__ import annotations
23
24 import asyncio
25 import logging
26 import pathlib
27 from typing import Optional
28
29 import typer
30 from sqlalchemy.ext.asyncio import AsyncSession
31
32 from maestro.config import settings
33 from maestro.muse_cli._repo import require_repo
34 from maestro.muse_cli.db import find_commits_by_prefix, open_session
35 from maestro.muse_cli.errors import ExitCode
36 from maestro.muse_cli.export_engine import (
37 ExportFormat,
38 MuseExportOptions,
39 MuseExportResult,
40 StorpheusUnavailableError,
41 export_snapshot,
42 resolve_commit_id,
43 )
44
45 logger = logging.getLogger(__name__)
46
47 app = typer.Typer()
48
49 _DEFAULT_EXPORTS_DIR = "exports"
50
51
52 def _default_output_path(commit_id: str, fmt: ExportFormat) -> pathlib.Path:
53 """Return the default output path for an export.
54
55 Pattern: ``./exports/<commit8>.<format>``. For MIDI with --split-tracks
56 or multi-file exports the caller converts this to a directory.
57 """
58 short = commit_id[:8]
59 ext = fmt.value
60 return pathlib.Path(_DEFAULT_EXPORTS_DIR) / f"{short}.{ext}"
61
62
63 async def _export_async(
64 *,
65 commit_ref: Optional[str],
66 fmt: ExportFormat,
67 output: Optional[pathlib.Path],
68 track: Optional[str],
69 section: Optional[str],
70 split_tracks: bool,
71 root: pathlib.Path,
72 session: AsyncSession,
73 ) -> MuseExportResult:
74 """Core export logic — injectable for tests.
75
76 Resolves the commit, loads the snapshot manifest, and dispatches to the
77 appropriate format handler via export_snapshot.
78
79 Args:
80 commit_ref: Short commit ID prefix or None for HEAD.
81 fmt: Target export format.
82 output: Explicit output path or None for default.
83 track: Track name filter.
84 section: Section name filter.
85 split_tracks: Whether to write one file per track (MIDI only).
86 root: Muse repository root.
87 session: Open async DB session.
88
89 Returns:
90 MuseExportResult describing what was written.
91
92 Raises:
93 typer.Exit: On user errors (no commits, bad prefix, etc.).
94 StorpheusUnavailableError: When WAV and Storpheus is down.
95 """
96 from maestro.muse_cli.db import get_commit_snapshot_manifest
97 # Resolve commit ID from filesystem HEAD or prefix lookup.
98 try:
99 raw_ref = resolve_commit_id(root, commit_ref)
100 except ValueError as exc:
101 typer.echo(f"❌ {exc}")
102 raise typer.Exit(code=ExitCode.USER_ERROR)
103
104 # If a prefix was supplied, look it up in the DB.
105 if len(raw_ref) < 64:
106 matches = await find_commits_by_prefix(session, raw_ref)
107 if not matches:
108 typer.echo(f"❌ No commit found matching prefix '{raw_ref[:8]}'")
109 raise typer.Exit(code=ExitCode.USER_ERROR)
110 if len(matches) > 1:
111 typer.echo(
112 f"❌ Ambiguous commit prefix '{raw_ref[:8]}' "
113 f"— matches {len(matches)} commits:"
114 )
115 for c in matches:
116 typer.echo(f" {c.commit_id[:8]} {c.message[:60]}")
117 typer.echo("Use a longer prefix to disambiguate.")
118 raise typer.Exit(code=ExitCode.USER_ERROR)
119 full_commit_id = matches[0].commit_id
120 else:
121 full_commit_id = raw_ref
122
123 # Load the snapshot manifest.
124 manifest = await get_commit_snapshot_manifest(session, full_commit_id)
125 if manifest is None:
126 typer.echo(f"❌ Commit {full_commit_id[:8]} not found in database.")
127 raise typer.Exit(code=ExitCode.USER_ERROR)
128
129 if not manifest:
130 typer.echo(f"⚠️ Snapshot for commit {full_commit_id[:8]} is empty — nothing to export.")
131 raise typer.Exit(code=ExitCode.USER_ERROR)
132
133 # Resolve output path.
134 out_path = output if output is not None else _default_output_path(full_commit_id, fmt)
135
136 storpheus_url = settings.storpheus_base_url
137
138 opts = MuseExportOptions(
139 format=fmt,
140 commit_id=full_commit_id,
141 output_path=out_path,
142 track=track,
143 section=section,
144 split_tracks=split_tracks,
145 )
146
147 return export_snapshot(manifest, root, opts, storpheus_url=storpheus_url)
148
149
150 @app.callback(invoke_without_command=True)
151 def export(
152 ctx: typer.Context,
153 commit: Optional[str] = typer.Argument(
154 None,
155 help="Short commit ID prefix to export (default: HEAD).",
156 show_default=False,
157 ),
158 fmt: ExportFormat = typer.Option(
159 ...,
160 "--format",
161 "-f",
162 help="Export format: midi | json | musicxml | abc | wav.",
163 case_sensitive=False,
164 ),
165 output: Optional[pathlib.Path] = typer.Option(
166 None,
167 "--output",
168 "-o",
169 help="Output path (default: ./exports/<commit8>.<format>).",
170 ),
171 track: Optional[str] = typer.Option(
172 None,
173 "--track",
174 help="Export only files matching this track name substring.",
175 ),
176 section: Optional[str] = typer.Option(
177 None,
178 "--section",
179 help="Export only files matching this section name substring.",
180 ),
181 split_tracks: bool = typer.Option(
182 False,
183 "--split-tracks",
184 help="Write one file per track (MIDI only).",
185 ),
186 ) -> None:
187 """Export a Muse snapshot to an external format.
188
189 Exports the snapshot referenced by COMMIT (default: HEAD) to the
190 specified format. This is a read-only operation — no commit is created.
191
192 Supported formats:
193 midi Raw MIDI file(s) — native format, lossless.
194 json Structured JSON note index (AI/tooling consumption).
195 musicxml MusicXML for notation software (MuseScore, Sibelius, etc.).
196 abc ABC notation for folk/traditional music tools.
197 wav Audio render via Storpheus (requires Storpheus running).
198 """
199 root = require_repo()
200
201 async def _run() -> MuseExportResult:
202 async with open_session() as session:
203 return await _export_async(
204 commit_ref=commit,
205 fmt=fmt,
206 output=output,
207 track=track,
208 section=section,
209 split_tracks=split_tracks,
210 root=root,
211 session=session,
212 )
213
214 try:
215 result = asyncio.run(_run())
216 except typer.Exit:
217 raise
218 except StorpheusUnavailableError as exc:
219 typer.echo(f"❌ WAV export requires Storpheus.\n{exc}")
220 logger.error("WAV export: Storpheus unavailable: %s", exc)
221 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)
222 except Exception as exc:
223 typer.echo(f"❌ muse export failed: {exc}")
224 logger.error("muse export error: %s", exc, exc_info=True)
225 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)
226
227 # Report results.
228 if not result.paths_written:
229 typer.echo(
230 f"⚠️ No {fmt.value} files found in snapshot {result.commit_id[:8]}."
231 )
232 if result.skipped_count:
233 typer.echo(f" ({result.skipped_count} files skipped — wrong type or missing.)")
234 raise typer.Exit(code=ExitCode.SUCCESS)
235
236 typer.echo(f"✅ Exported {len(result.paths_written)} file(s) [{fmt.value}]:")
237 for p in result.paths_written:
238 typer.echo(f" {p}")
239 logger.info(
240 "muse export: commit=%s format=%s files=%d",
241 result.commit_id[:8],
242 fmt.value,
243 len(result.paths_written),
244 )