export.py
python
| 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 | ) |