cgcardona / muse public
attributes.py python
290 lines 8.9 KB
9ee9c39c refactor: rename music→midi domain, strip all 5-dim backward compat Gabriel Cardona <gabriel@tellurstori.com> 1d ago
1 """Muse attributes — ``.museattributes`` TOML parser and per-path strategy resolver.
2
3 ``.museattributes`` lives in the repository root (next to ``.muse/`` and
4 ``muse-work/``) and declares merge strategies for specific paths and
5 dimensions. It uses TOML syntax with an optional ``[meta]`` section for
6 domain declaration and an ordered ``[[rules]]`` array.
7
8 Format
9 ------
10
11 .. code-block:: toml
12
13 # .museattributes
14 # Merge strategy overrides for this repository.
15
16 [meta]
17 domain = "midi" # optional — validated against .muse/repo.json
18
19 [[rules]]
20 path = "drums/*" # fnmatch glob against workspace-relative POSIX paths
21 dimension = "*" # domain axis name, or "*" to match any dimension
22 strategy = "ours"
23
24 [[rules]]
25 path = "keys/*"
26 dimension = "pitch_bend"
27 strategy = "theirs"
28
29 [[rules]]
30 path = "*"
31 dimension = "*"
32 strategy = "auto"
33
34 **path** — ``fnmatch`` glob matched against workspace-relative POSIX paths.
35 **dimension** — a domain-defined axis name (e.g. ``notes``, ``pitch_bend``)
36 or ``*`` to match any dimension.
37 **strategy** — one of ``ours | theirs | union | auto | manual``.
38
39 **First matching rule wins.** ``[meta]`` is optional; its absence has no
40 effect on merge correctness. When both ``[meta] domain`` and a repo
41 ``domain`` are known, a mismatch logs a warning.
42
43 Public API
44 ----------
45
46 - :class:`AttributesMeta` — TypedDict for the ``[meta]`` section.
47 - :class:`AttributesRuleDict` — TypedDict for a single ``[[rules]]`` entry.
48 - :class:`MuseAttributesFile` — TypedDict for the full parsed file.
49 - :class:`AttributeRule` — a single resolved rule (dataclass).
50 - :func:`read_attributes_meta` — read only the ``[meta]`` section.
51 - :func:`load_attributes` — read ``.museattributes`` from a repo root.
52 - :func:`resolve_strategy` — first-match strategy lookup.
53 """
54 from __future__ import annotations
55
56 import fnmatch
57 import logging
58 import pathlib
59 import tomllib
60 from dataclasses import dataclass
61 from typing import TypedDict
62
63 _logger = logging.getLogger(__name__)
64
65 VALID_STRATEGIES: frozenset[str] = frozenset(
66 {"ours", "theirs", "union", "auto", "manual"}
67 )
68
69 _FILENAME = ".museattributes"
70
71
72 class AttributesMeta(TypedDict, total=False):
73 """Typed representation of the ``[meta]`` section in ``.museattributes``."""
74
75 domain: str
76
77
78 class AttributesRuleDict(TypedDict):
79 """Typed representation of a single ``[[rules]]`` entry."""
80
81 path: str
82 dimension: str
83 strategy: str
84
85
86 class MuseAttributesFile(TypedDict, total=False):
87 """Typed representation of the complete ``.museattributes`` file."""
88
89 meta: AttributesMeta
90 rules: list[AttributesRuleDict]
91
92
93 @dataclass(frozen=True)
94 class AttributeRule:
95 """A single rule resolved from ``.museattributes``.
96
97 Attributes:
98 path_pattern: ``fnmatch`` glob matched against workspace-relative paths.
99 dimension: Domain axis name (e.g. ``"notes"``) or ``"*"``.
100 strategy: Resolution strategy: ``ours | theirs | union | auto | manual``.
101 source_index: 0-based index of the rule in the ``[[rules]]`` array.
102 """
103
104 path_pattern: str
105 dimension: str
106 strategy: str
107 source_index: int = 0
108
109
110 def _parse_raw(root: pathlib.Path) -> MuseAttributesFile:
111 """Read and TOML-parse ``.museattributes``, returning a typed file structure.
112
113 Builds ``MuseAttributesFile`` from the raw TOML dict using explicit
114 ``isinstance`` checks — no ``Any`` propagated into the return value.
115
116 Raises:
117 ValueError: On TOML syntax errors.
118 """
119 attr_file = root / _FILENAME
120 raw_bytes = attr_file.read_bytes()
121 try:
122 raw = tomllib.loads(raw_bytes.decode("utf-8"))
123 except tomllib.TOMLDecodeError as exc:
124 raise ValueError(f"{_FILENAME}: TOML parse error — {exc}") from exc
125
126 result: MuseAttributesFile = {}
127
128 # [meta] section
129 meta_raw = raw.get("meta")
130 if isinstance(meta_raw, dict):
131 meta: AttributesMeta = {}
132 domain_val = meta_raw.get("domain")
133 if isinstance(domain_val, str):
134 meta["domain"] = domain_val
135 result["meta"] = meta
136
137 # [[rules]] array
138 rules_raw = raw.get("rules")
139 if isinstance(rules_raw, list):
140 rules: list[AttributesRuleDict] = []
141 for idx, entry in enumerate(rules_raw):
142 if not isinstance(entry, dict):
143 continue
144 path_val = entry.get("path")
145 dim_val = entry.get("dimension")
146 strat_val = entry.get("strategy")
147 if (
148 isinstance(path_val, str)
149 and isinstance(dim_val, str)
150 and isinstance(strat_val, str)
151 ):
152 rules.append(
153 AttributesRuleDict(
154 path=path_val,
155 dimension=dim_val,
156 strategy=strat_val,
157 )
158 )
159 else:
160 missing = [
161 f for f, v in (("path", path_val), ("dimension", dim_val), ("strategy", strat_val))
162 if not isinstance(v, str)
163 ]
164 raise ValueError(
165 f"{_FILENAME}: rule[{idx}] is missing required field(s): "
166 + ", ".join(missing)
167 )
168 result["rules"] = rules
169
170 return result
171
172
173 def read_attributes_meta(root: pathlib.Path) -> AttributesMeta:
174 """Return the ``[meta]`` section of ``.museattributes``, or an empty dict.
175
176 Does not validate or resolve rules — use this to inspect metadata only.
177
178 Args:
179 root: Repository root directory.
180
181 Returns:
182 The ``[meta]`` TypedDict, which may be empty if the section is absent
183 or the file does not exist.
184 """
185 attr_file = root / _FILENAME
186 if not attr_file.exists():
187 return {}
188 try:
189 parsed = _parse_raw(root)
190 except ValueError:
191 return {}
192 meta = parsed.get("meta")
193 if meta is None:
194 return {}
195 return meta
196
197
198 def load_attributes(
199 root: pathlib.Path,
200 *,
201 domain: str | None = None,
202 ) -> list[AttributeRule]:
203 """Parse ``.museattributes`` from *root* and return the ordered rule list.
204
205 Args:
206 root: Repository root directory (the directory that contains ``.muse/``
207 and ``muse-work/``).
208 domain: Optional domain name from the active repository. When provided
209 and the file contains ``[meta] domain``, a mismatch logs a
210 warning. Pass ``None`` to skip domain validation.
211
212 Returns:
213 A list of :class:`AttributeRule` in file order. Returns an empty list
214 when ``.museattributes`` is absent or contains no valid rules.
215
216 Raises:
217 ValueError: If a rule entry is missing required fields, or contains an
218 invalid strategy.
219 """
220 attr_file = root / _FILENAME
221 if not attr_file.exists():
222 return []
223
224 data = _parse_raw(root)
225
226 # Domain validation
227 meta = data.get("meta", {})
228 file_domain = meta.get("domain") if meta else None
229 if file_domain and domain and file_domain != domain:
230 _logger.warning(
231 "⚠️ %s: [meta] domain %r does not match active repo domain %r — "
232 "rules may target a different domain",
233 _FILENAME,
234 file_domain,
235 domain,
236 )
237
238 raw_rules = data.get("rules", [])
239
240 rules: list[AttributeRule] = []
241 for idx, entry in enumerate(raw_rules):
242 strategy = entry["strategy"]
243 if strategy not in VALID_STRATEGIES:
244 raise ValueError(
245 f"{_FILENAME}: rule[{idx}]: unknown strategy {strategy!r}. "
246 f"Valid strategies: {sorted(VALID_STRATEGIES)}"
247 )
248
249 rules.append(
250 AttributeRule(
251 path_pattern=entry["path"],
252 dimension=entry["dimension"],
253 strategy=strategy,
254 source_index=idx,
255 )
256 )
257
258 return rules
259
260
261 def resolve_strategy(
262 rules: list[AttributeRule],
263 path: str,
264 dimension: str = "*",
265 ) -> str:
266 """Return the first matching strategy for *path* and *dimension*.
267
268 Matching rules:
269
270 - **path**: ``fnmatch.fnmatch(path, rule.path_pattern)`` must be ``True``.
271 - **dimension**: ``rule.dimension`` must be ``"*"`` (matches anything) **or**
272 equal *dimension*.
273
274 First-match wins. Returns ``"auto"`` when no rule matches.
275
276 Args:
277 rules: Rule list from :func:`load_attributes`.
278 path: Workspace-relative POSIX path (e.g. ``"tracks/drums.mid"``).
279 dimension: Domain axis name or ``"*"`` to match any rule dimension.
280
281 Returns:
282 A strategy string: ``"ours"``, ``"theirs"``, ``"union"``, ``"auto"``,
283 or ``"manual"``.
284 """
285 for rule in rules:
286 path_match = fnmatch.fnmatch(path, rule.path_pattern)
287 dim_match = rule.dimension == "*" or rule.dimension == dimension or dimension == "*"
288 if path_match and dim_match:
289 return rule.strategy
290 return "auto"