cgcardona / muse public
muse_attributes.py python
207 lines 6.3 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """Muse Attributes — .museattributes parser and merge-strategy resolver.
2
3 ``.museattributes`` is a per-repository configuration file (placed in the
4 repository root, next to ``.muse/``) that declares merge strategies for
5 specific track patterns and musical dimensions.
6
7 File format (one rule per line)::
8
9 # comment
10 <track-pattern> <dimension> <strategy>
11
12 Where:
13 - ``<track-pattern>`` is an fnmatch glob (e.g. ``drums/*``, ``bass/*``, ``*``).
14 - ``<dimension>`` is a musical dimension name or ``*`` (all dimensions).
15 - ``<strategy>`` is one of: ``ours``, ``theirs``, ``union``, ``auto``, ``manual``.
16
17 Resolution precedence: the *first* matching rule wins.
18
19 Example::
20
21 # Drums are always authoritative — keep ours on conflict.
22 drums/* * ours
23 # Accept collaborator keys wholesale.
24 keys/* harmonic theirs
25 # Everything else: automatic merge.
26 * * auto
27 """
28 from __future__ import annotations
29
30 import fnmatch
31 import logging
32 from enum import Enum
33 from pathlib import Path
34
35 from pydantic import BaseModel
36
37 logger = logging.getLogger(__name__)
38
39 MUSEATTRIBUTES_FILENAME = ".museattributes"
40
41
42 # ---------------------------------------------------------------------------
43 # Data types
44 # ---------------------------------------------------------------------------
45
46
47 class MergeStrategy(str, Enum):
48 """Merge strategy choices for a musical dimension."""
49
50 OURS = "ours"
51 THEIRS = "theirs"
52 UNION = "union"
53 AUTO = "auto"
54 MANUAL = "manual"
55
56
57 class MuseAttribute(BaseModel):
58 """A single rule parsed from a ``.museattributes`` file."""
59
60 track_pattern: str
61 dimension: str
62 strategy: MergeStrategy
63
64 model_config = {"frozen": True}
65
66
67 # ---------------------------------------------------------------------------
68 # Parser
69 # ---------------------------------------------------------------------------
70
71
72 def parse_museattributes_file(content: str) -> list[MuseAttribute]:
73 """Parse the text content of a ``.museattributes`` file into a list of rules.
74
75 Lines that are empty or start with ``#`` are ignored. Each rule line must
76 contain exactly three whitespace-separated tokens; malformed lines are
77 logged as warnings and skipped.
78
79 Args:
80 content: Raw text content of the ``.museattributes`` file.
81
82 Returns:
83 Ordered list of ``MuseAttribute`` instances (first-match-wins).
84 """
85 attributes: list[MuseAttribute] = []
86
87 for lineno, raw_line in enumerate(content.splitlines(), start=1):
88 line = raw_line.strip()
89 if not line or line.startswith("#"):
90 continue
91
92 tokens = line.split()
93 if len(tokens) != 3:
94 logger.warning(
95 "⚠️ .museattributes line %d: expected 3 tokens, got %d — skipping: %r",
96 lineno,
97 len(tokens),
98 line,
99 )
100 continue
101
102 track_pattern, dimension, strategy_raw = tokens
103
104 try:
105 strategy = MergeStrategy(strategy_raw.lower())
106 except ValueError:
107 valid = ", ".join(s.value for s in MergeStrategy)
108 logger.warning(
109 "⚠️ .museattributes line %d: unknown strategy %r (valid: %s) — skipping",
110 lineno,
111 strategy_raw,
112 valid,
113 )
114 continue
115
116 attributes.append(
117 MuseAttribute(
118 track_pattern=track_pattern,
119 dimension=dimension,
120 strategy=strategy,
121 )
122 )
123
124 logger.debug("✅ Parsed %d rule(s) from .museattributes", len(attributes))
125 return attributes
126
127
128 # ---------------------------------------------------------------------------
129 # Loader
130 # ---------------------------------------------------------------------------
131
132
133 def load_attributes(repo_path: Path) -> list[MuseAttribute]:
134 """Load ``.museattributes`` from the repository root.
135
136 Args:
137 repo_path: Path to the Muse repository root (the directory that contains
138 the ``.muse/`` folder).
139
140 Returns:
141 Parsed list of ``MuseAttribute`` rules. Returns an empty list if the
142 file does not exist; never raises.
143 """
144 attr_file = repo_path / MUSEATTRIBUTES_FILENAME
145 if not attr_file.exists():
146 logger.debug("ℹ️ No .museattributes found at %s", attr_file)
147 return []
148
149 try:
150 content = attr_file.read_text(encoding="utf-8")
151 except OSError as exc:
152 logger.warning("⚠️ Could not read .museattributes: %s", exc)
153 return []
154
155 return parse_museattributes_file(content)
156
157
158 # ---------------------------------------------------------------------------
159 # Strategy resolver
160 # ---------------------------------------------------------------------------
161
162
163 def resolve_strategy(
164 attributes: list[MuseAttribute],
165 track: str,
166 dimension: str,
167 ) -> MergeStrategy:
168 """Return the configured ``MergeStrategy`` for a track + dimension pair.
169
170 Iterates through ``attributes`` in order (first-match-wins). The
171 ``track_pattern`` is matched using ``fnmatch`` so patterns like
172 ``drums/*``, ``*``, or ``bass/kick`` all work as expected. The
173 ``dimension`` is matched with fnmatch as well, allowing ``*`` to cover
174 all dimensions.
175
176 If no rule matches, returns ``MergeStrategy.AUTO`` (the safe default).
177
178 Args:
179 attributes: Ordered list of ``MuseAttribute`` rules (from
180 ``load_attributes`` or ``parse_museattributes_file``).
181 track: Concrete track name to resolve (e.g. ``"drums/kick"``).
182 dimension: Musical dimension name (e.g. ``"harmonic"``, ``"rhythmic"``).
183
184 Returns:
185 The first matching ``MergeStrategy``, or ``MergeStrategy.AUTO`` when
186 no rule matches.
187 """
188 for attr in attributes:
189 track_matches = fnmatch.fnmatch(track, attr.track_pattern)
190 dim_matches = fnmatch.fnmatch(dimension, attr.dimension)
191 if track_matches and dim_matches:
192 logger.debug(
193 "✅ .museattributes: track=%r dim=%r matched pattern=%r/%r → %s",
194 track,
195 dimension,
196 attr.track_pattern,
197 attr.dimension,
198 attr.strategy.value,
199 )
200 return attr.strategy
201
202 logger.debug(
203 "ℹ️ .museattributes: no rule matched track=%r dim=%r — defaulting to auto",
204 track,
205 dimension,
206 )
207 return MergeStrategy.AUTO