cgcardona / muse public
find.py python
227 lines 7.2 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """muse find — search commit history by musical properties.
2
3 This is the musical grep. Queries the full commit history for the current
4 repository and returns commits whose messages match the requested musical
5 criteria. All filters combine with AND logic.
6
7 Examples::
8
9 muse find --harmony "key=F minor"
10 muse find --rhythm "tempo=120-130" --since "2026-01-01"
11 muse find --emotion melancholic --structure "has=bridge" --json
12 muse find --track "bass" --limit 10
13
14 Output modes
15 ------------
16 Default: one commit per line, ``git log``-style.
17 ``--json``: machine-readable JSON array of commit objects.
18 """
19 from __future__ import annotations
20
21 import asyncio
22 import json
23 import logging
24 import pathlib
25 from datetime import datetime, timezone
26
27 import typer
28 from sqlalchemy.ext.asyncio import AsyncSession
29
30 from maestro.muse_cli._repo import require_repo
31 from maestro.muse_cli.db import open_session
32 from maestro.muse_cli.errors import ExitCode
33 from maestro.services.muse_find import (
34 MuseFindCommitResult,
35 MuseFindQuery,
36 MuseFindResults,
37 search_commits,
38 )
39
40 logger = logging.getLogger(__name__)
41
42 app = typer.Typer()
43
44 _DEFAULT_LIMIT = 20
45
46
47 def _load_repo_id(root: pathlib.Path) -> str:
48 """Read ``repo_id`` from ``.muse/repo.json``."""
49 muse_dir = root / ".muse"
50 repo_data: dict[str, str] = json.loads((muse_dir / "repo.json").read_text())
51 return repo_data["repo_id"]
52
53
54 # ---------------------------------------------------------------------------
55 # Testable async core
56 # ---------------------------------------------------------------------------
57
58
59 async def _find_async(
60 *,
61 root: pathlib.Path,
62 session: AsyncSession,
63 query: MuseFindQuery,
64 output_json: bool,
65 ) -> MuseFindResults:
66 """Execute the find query and render output.
67
68 Injectable for tests: callers pass a session and tmp_path root.
69 Returns the :class:`MuseFindResults` so tests can inspect matches
70 without parsing printed output.
71 """
72 repo_id = _load_repo_id(root)
73 results = await search_commits(session, repo_id, query)
74
75 if output_json:
76 _render_json(results)
77 else:
78 _render_text(results)
79
80 return results
81
82
83 # ---------------------------------------------------------------------------
84 # Renderers
85 # ---------------------------------------------------------------------------
86
87
88 def _commit_to_dict(commit: MuseFindCommitResult) -> dict[str, object]:
89 """Serialise a :class:`MuseFindCommitResult` to a JSON-serialisable dict."""
90 return {
91 "commit_id": commit.commit_id,
92 "branch": commit.branch,
93 "message": commit.message,
94 "author": commit.author,
95 "committed_at": commit.committed_at.isoformat(),
96 "parent_commit_id": commit.parent_commit_id,
97 "snapshot_id": commit.snapshot_id,
98 }
99
100
101 def _render_json(results: MuseFindResults) -> None:
102 """Print matching commits as a JSON array."""
103 payload: list[dict[str, object]] = [
104 _commit_to_dict(c) for c in results.matches
105 ]
106 typer.echo(json.dumps(payload, indent=2))
107
108
109 def _render_text(results: MuseFindResults) -> None:
110 """Print matching commits in ``git log``-style, newest-first."""
111 if not results.matches:
112 typer.echo("No commits match the given criteria.")
113 return
114
115 for commit in results.matches:
116 typer.echo(f"commit {commit.commit_id}")
117 if commit.parent_commit_id:
118 typer.echo(f"Branch: {commit.branch}")
119 typer.echo(f"Parent: {commit.parent_commit_id[:8]}")
120 ts = commit.committed_at.strftime("%Y-%m-%d %H:%M:%S")
121 typer.echo(f"Date: {ts}")
122 typer.echo("")
123 typer.echo(f" {commit.message}")
124 typer.echo("")
125
126
127 # ---------------------------------------------------------------------------
128 # Typer command
129 # ---------------------------------------------------------------------------
130
131
132 @app.callback(invoke_without_command=True)
133 def find(
134 ctx: typer.Context,
135 harmony: str | None = typer.Option(
136 None, "--harmony", help='Harmonic filter, e.g. "key=Eb" or "mode=minor".'
137 ),
138 rhythm: str | None = typer.Option(
139 None, "--rhythm", help='Rhythmic filter, e.g. "tempo=120-130" or "meter=7/8".'
140 ),
141 melody: str | None = typer.Option(
142 None, "--melody", help='Melodic filter, e.g. "range>2oct" or "shape=arch".'
143 ),
144 structure: str | None = typer.Option(
145 None, "--structure", help='Structural filter, e.g. "has=bridge" or "form=AABA".'
146 ),
147 dynamic: str | None = typer.Option(
148 None, "--dynamic", help='Dynamic filter, e.g. "avg_vel>80" or "arc=crescendo".'
149 ),
150 emotion: str | None = typer.Option(
151 None, "--emotion", help="Emotion tag, e.g. melancholic."
152 ),
153 section: str | None = typer.Option(
154 None, "--section", help="Find commits containing a named section."
155 ),
156 track: str | None = typer.Option(
157 None, "--track", help="Find commits where a specific track was present."
158 ),
159 since: str | None = typer.Option(
160 None, "--since", help="Restrict to commits after this date (YYYY-MM-DD)."
161 ),
162 until: str | None = typer.Option(
163 None, "--until", help="Restrict to commits before this date (YYYY-MM-DD)."
164 ),
165 limit: int = typer.Option(
166 _DEFAULT_LIMIT,
167 "--limit",
168 "-n",
169 help="Maximum number of results to return.",
170 min=1,
171 ),
172 output_json: bool = typer.Option(
173 False, "--json", help="Output results as JSON."
174 ),
175 ) -> None:
176 """Search commit history by musical properties."""
177 # Validate that at least one filter is provided
178 all_filters = [harmony, rhythm, melody, structure, dynamic, emotion, section, track, since, until]
179 if all(f is None for f in all_filters):
180 typer.echo("❌ Provide at least one filter flag. See --help for options.")
181 raise typer.Exit(code=ExitCode.USER_ERROR)
182
183 # Parse dates
184 since_dt: datetime | None = None
185 until_dt: datetime | None = None
186
187 if since is not None:
188 try:
189 since_dt = datetime.fromisoformat(since).replace(tzinfo=timezone.utc)
190 except ValueError:
191 typer.echo(f"❌ Invalid --since date: {since!r}. Use YYYY-MM-DD format.")
192 raise typer.Exit(code=ExitCode.USER_ERROR)
193
194 if until is not None:
195 try:
196 until_dt = datetime.fromisoformat(until).replace(tzinfo=timezone.utc)
197 except ValueError:
198 typer.echo(f"❌ Invalid --until date: {until!r}. Use YYYY-MM-DD format.")
199 raise typer.Exit(code=ExitCode.USER_ERROR)
200
201 root = require_repo()
202 query = MuseFindQuery(
203 harmony=harmony,
204 rhythm=rhythm,
205 melody=melody,
206 structure=structure,
207 dynamic=dynamic,
208 emotion=emotion,
209 section=section,
210 track=track,
211 since=since_dt,
212 until=until_dt,
213 limit=limit,
214 )
215
216 async def _run() -> None:
217 async with open_session() as session:
218 await _find_async(root=root, session=session, query=query, output_json=output_json)
219
220 try:
221 asyncio.run(_run())
222 except typer.Exit:
223 raise
224 except Exception as exc:
225 typer.echo(f"❌ muse find failed: {exc}")
226 logger.error("❌ muse find error: %s", exc, exc_info=True)
227 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)