gabriel / muse public
attributes.py python
410 lines 13.8 KB
Raw
sha256:37af4ab271a64f11d7bf14b4871ebaaf5dd68fb0247521007c6b034d35aa01be Merge branch 'fix/hub-user-read-update-path' into dev Human 10 days 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 ``state/``) 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" # resolution strategy (see below)
23 comment = "Drums are always authored by branch A — always prefer ours."
24 priority = 10 # optional; higher priority rules are tried first
25
26 [[rules]]
27 path = "keys/*"
28 dimension = "pitch_bend"
29 strategy = "theirs"
30 comment = "Remote always has the better pitch-bend automation."
31
32 [[rules]]
33 path = "*"
34 dimension = "*"
35 strategy = "auto"
36
37 Strategies
38 ----------
39
40 ``ours``
41 Take the left / current-branch version; the path is removed from the
42 conflict list.
43
44 ``theirs``
45 Take the right / incoming-branch version; the path is removed from the
46 conflict list.
47
48 ``union``
49 Include **all** additions from both sides. Deletions are honoured only
50 when **both** sides agree. For independent element sets (MIDI notes,
51 code symbol additions, import sets) this produces a combined result with
52 no conflicts. For opaque binary blobs where full unification is
53 impossible, the left / current-branch blob is preferred and the path is
54 removed from the conflict list.
55
56 ``base``
57 Revert to the common merge-base version — discard changes from *both*
58 branches. Useful for generated files, lock files, or any path that
59 should always stay at a known-good state during a merge.
60
61 ``auto``
62 Default behaviour. Defer to the engine's three-way algorithm.
63
64 ``manual``
65 Force the path into the conflict list even if the engine would
66 auto-resolve it. Use this to guarantee human review on safety-critical
67 paths.
68
69 Rule fields
70 -----------
71
72 ``path`` (required) — ``fnmatch`` glob matched against workspace-relative
73 POSIX paths (e.g. ``"tracks/*.mid"``, ``"src/**/*.py"``).
74
75 ``dimension`` (required) — domain axis name (e.g. ``"notes"``,
76 ``"pitch_bend"``, ``"symbols"``) or ``"*"`` to match any
77 dimension.
78
79 ``strategy`` (required) — one of the six strategies listed above.
80
81 ``comment`` (optional) — free-form documentation string; ignored at
82 runtime. Use it to explain *why* the rule exists.
83
84 ``priority`` (optional, default 0) — integer used to order rules before
85 file order. Higher-priority rules are evaluated first. Rules
86 with equal priority preserve their declaration order.
87
88 **First matching rule wins** after sorting by priority (descending) then
89 file order (ascending).
90
91 ``[meta]`` is optional; its absence has no effect on merge correctness.
92 When both ``[meta] domain`` and a repo ``domain`` are known, a mismatch
93 logs a warning.
94
95 Public API
96 ----------
97
98 - :class:`AttributesMeta` — TypedDict for the ``[meta]`` section.
99 - :class:`AttributesRuleDict` — TypedDict for a single ``[[rules]]`` entry.
100 - :class:`MuseAttributesFile` — TypedDict for the full parsed file.
101 - :class:`AttributeRule` — a single resolved rule (dataclass).
102 - :func:`read_attributes_meta` — read only the ``[meta]`` section.
103 - :func:`load_attributes_full` — single-pass parse returning ``(meta, rules)``.
104 - :func:`load_attributes` — read ``.museattributes`` from a repo root.
105 - :func:`resolve_strategy` — first-match strategy lookup.
106 """
107
108 import fnmatch
109 import logging
110 import pathlib
111 import tomllib
112 from dataclasses import dataclass, field
113 from typing import TypedDict
114
115 _logger = logging.getLogger(__name__)
116
117 VALID_STRATEGIES: frozenset[str] = frozenset(
118 {"ours", "theirs", "union", "base", "auto", "manual"}
119 )
120
121 _FILENAME = ".museattributes"
122
123 # 1 MiB cap — prevents OOM from a crafted or corrupted .museattributes.
124 _MAX_ATTRIBUTES_BYTES: int = 1 * 1024 * 1024
125
126 class AttributesMeta(TypedDict, total=False):
127 """Typed representation of the ``[meta]`` section in ``.museattributes``."""
128
129 domain: str
130
131 class AttributesRuleDict(TypedDict, total=False):
132 """Typed representation of a single ``[[rules]]`` entry.
133
134 ``path``, ``dimension``, and ``strategy`` are required at parse time.
135 ``comment`` and ``priority`` are optional.
136 """
137
138 path: str
139 dimension: str
140 strategy: str
141 comment: str
142 priority: int
143
144 class MuseAttributesFile(TypedDict, total=False):
145 """Typed representation of the complete ``.museattributes`` file."""
146
147 meta: AttributesMeta
148 rules: list[AttributesRuleDict]
149
150 @dataclass(frozen=True)
151 class AttributeRule:
152 """A single rule resolved from ``.museattributes``.
153
154 Attributes:
155 path_pattern: ``fnmatch`` glob matched against workspace-relative paths.
156 dimension: Domain axis name (e.g. ``"notes"``) or ``"*"``.
157 strategy: Resolution strategy: one of ``ours | theirs | union |
158 base | auto | manual``.
159 comment: Human-readable annotation explaining the rule's purpose.
160 Ignored at runtime.
161 priority: Ordering weight. Higher values are evaluated before
162 lower values. Rules with equal priority preserve
163 declaration order.
164 source_index: 0-based index of the rule in the ``[[rules]]`` array.
165 """
166
167 path_pattern: str
168 dimension: str
169 strategy: str
170 comment: str = ""
171 priority: int = 0
172 source_index: int = 0
173
174 def _parse_raw(root: pathlib.Path) -> MuseAttributesFile:
175 """Read and TOML-parse ``.museattributes``, returning a typed file structure.
176
177 Builds ``MuseAttributesFile`` from the raw TOML dict using explicit
178 ``isinstance`` checks — no ``Any`` propagated into the return value.
179
180 Raises:
181 ValueError: On TOML syntax errors or if the file exceeds
182 ``_MAX_ATTRIBUTES_BYTES``.
183 """
184 attr_file = root / _FILENAME
185 raw_bytes = attr_file.read_bytes()
186 if len(raw_bytes) > _MAX_ATTRIBUTES_BYTES:
187 raise ValueError(
188 f"{_FILENAME}: file too large ({len(raw_bytes):,} bytes > "
189 f"{_MAX_ATTRIBUTES_BYTES:,} byte limit)"
190 )
191 try:
192 raw = tomllib.loads(raw_bytes.decode("utf-8"))
193 except tomllib.TOMLDecodeError as exc:
194 raise ValueError(f"{_FILENAME}: TOML parse error — {exc}") from exc
195
196 result: MuseAttributesFile = {}
197
198 # [meta] section
199 meta_raw = raw.get("meta")
200 if isinstance(meta_raw, dict):
201 meta: AttributesMeta = {}
202 domain_val = meta_raw.get("domain")
203 if isinstance(domain_val, str):
204 meta["domain"] = domain_val
205 result["meta"] = meta
206
207 # [[rules]] array
208 rules_raw = raw.get("rules")
209 if isinstance(rules_raw, list):
210 rules: list[AttributesRuleDict] = []
211 for idx, entry in enumerate(rules_raw):
212 if not isinstance(entry, dict):
213 continue
214 path_val = entry.get("path")
215 dim_val = entry.get("dimension")
216 strat_val = entry.get("strategy")
217 if (
218 isinstance(path_val, str)
219 and isinstance(dim_val, str)
220 and isinstance(strat_val, str)
221 ):
222 rule: AttributesRuleDict = {
223 "path": path_val,
224 "dimension": dim_val,
225 "strategy": strat_val,
226 }
227 comment_val = entry.get("comment")
228 if isinstance(comment_val, str):
229 rule["comment"] = comment_val
230 priority_val = entry.get("priority")
231 if isinstance(priority_val, int):
232 rule["priority"] = priority_val
233 rules.append(rule)
234 else:
235 missing = [
236 f
237 for f, v in (
238 ("path", path_val),
239 ("dimension", dim_val),
240 ("strategy", strat_val),
241 )
242 if not isinstance(v, str)
243 ]
244 raise ValueError(
245 f"{_FILENAME}: rule[{idx}] is missing required field(s): "
246 f"{', '.join(missing)}"
247 )
248 result["rules"] = rules
249
250 return result
251
252 def read_attributes_meta(root: pathlib.Path) -> AttributesMeta:
253 """Return the ``[meta]`` section of ``.museattributes``, or an empty dict.
254
255 Does not validate or resolve rules — use this to inspect metadata only.
256
257 Args:
258 root: Repository root directory.
259
260 Returns:
261 The ``[meta]`` TypedDict, which may be empty if the section is absent
262 or the file does not exist.
263 """
264 attr_file = root / _FILENAME
265 if not attr_file.exists():
266 return {}
267 try:
268 parsed = _parse_raw(root)
269 except ValueError:
270 return {}
271 meta = parsed.get("meta")
272 if meta is None:
273 return {}
274 return meta
275
276 def load_attributes_full(
277 root: pathlib.Path,
278 *,
279 domain: str | None = None,
280 ) -> tuple[AttributesMeta, list[AttributeRule]]:
281 """Parse ``.museattributes`` in a **single pass** and return both meta and rules.
282
283 This is the preferred entry point when both the ``[meta]`` section and the
284 rule list are needed — it avoids parsing the file twice (unlike calling
285 :func:`read_attributes_meta` followed by :func:`load_attributes`).
286
287 Rules are sorted by ``priority`` (descending) then by declaration order
288 (ascending), so higher-priority rules are evaluated first.
289
290 Args:
291 root: Repository root directory.
292 domain: Optional active repo domain. When provided and the file
293 contains ``[meta] domain``, a mismatch logs a warning.
294 Pass ``None`` to skip the check.
295
296 Returns:
297 ``(meta, rules)`` — *meta* is the ``[meta]`` TypedDict (possibly
298 empty); *rules* is the priority-sorted rule list (possibly empty).
299
300 Raises:
301 ValueError: If a rule is missing required fields, contains an invalid
302 strategy, or the file exceeds ``_MAX_ATTRIBUTES_BYTES``.
303 """
304 attr_file = root / _FILENAME
305 if not attr_file.exists():
306 return {}, []
307
308 data = _parse_raw(root)
309
310 meta: AttributesMeta = data.get("meta") or {}
311
312 # Domain mismatch warning — advisory only, does not raise.
313 file_domain = meta.get("domain")
314 if file_domain and domain and file_domain != domain:
315 _logger.warning(
316 "⚠️ %s: [meta] domain %r does not match active repo domain %r — "
317 "rules may target a different domain",
318 _FILENAME,
319 file_domain,
320 domain,
321 )
322
323 raw_rules: list[AttributesRuleDict] = data.get("rules") or []
324
325 rules: list[AttributeRule] = []
326 for idx, entry in enumerate(raw_rules):
327 strategy = entry["strategy"]
328 if strategy not in VALID_STRATEGIES:
329 raise ValueError(
330 f"{_FILENAME}: rule[{idx}]: unknown strategy {strategy!r}. "
331 f"Valid strategies: {sorted(VALID_STRATEGIES)}"
332 )
333
334 rules.append(
335 AttributeRule(
336 path_pattern=entry["path"],
337 dimension=entry["dimension"],
338 strategy=strategy,
339 comment=entry.get("comment", ""),
340 priority=entry.get("priority", 0),
341 source_index=idx,
342 )
343 )
344
345 # Stable sort: higher priority first, ties preserve declaration order.
346 rules.sort(key=lambda r: -r.priority)
347 return meta, rules
348
349 def load_attributes(
350 root: pathlib.Path,
351 *,
352 domain: str | None = None,
353 ) -> list[AttributeRule]:
354 """Parse ``.museattributes`` from *root* and return the ordered rule list.
355
356 Delegates to :func:`load_attributes_full` — use that function directly
357 when both the ``[meta]`` section and the rule list are needed, to avoid
358 parsing the file twice.
359
360 Args:
361 root: Repository root directory.
362 domain: Optional active repo domain for mismatch warning.
363
364 Returns:
365 A list of :class:`AttributeRule` sorted by priority then file order.
366 Returns an empty list when ``.museattributes`` is absent or has no
367 valid rules.
368
369 Raises:
370 ValueError: If a rule is missing required fields or has an invalid
371 strategy.
372 """
373 _, rules = load_attributes_full(root, domain=domain)
374 return rules
375
376 def resolve_strategy(
377 rules: list[AttributeRule],
378 path: str,
379 dimension: str = "*",
380 ) -> str:
381 """Return the first matching strategy for *path* and *dimension*.
382
383 Matching rules:
384
385 - **path**: ``fnmatch.fnmatch(path, rule.path_pattern)`` must be ``True``.
386 - **dimension**: ``rule.dimension`` must be ``"*"`` (matches anything) **or**
387 equal *dimension*.
388
389 First-match wins after priority ordering applied by :func:`load_attributes`.
390 Returns ``"auto"`` when no rule matches.
391
392 Args:
393 rules: Rule list from :func:`load_attributes`.
394 path: Workspace-relative POSIX path (e.g. ``"tracks/drums.mid"``).
395 dimension: Domain axis name or ``"*"`` to match any rule dimension.
396
397 Returns:
398 A strategy string: ``"ours"``, ``"theirs"``, ``"union"``, ``"base"``,
399 ``"auto"``, or ``"manual"``.
400 """
401 for rule in rules:
402 path_match = fnmatch.fnmatch(path, rule.path_pattern)
403 dim_match = (
404 rule.dimension == "*"
405 or rule.dimension == dimension
406 or dimension == "*"
407 )
408 if path_match and dim_match:
409 return rule.strategy
410 return "auto"
File History 11 commits
sha256:37af4ab271a64f11d7bf14b4871ebaaf5dd68fb0247521007c6b034d35aa01be Merge branch 'fix/hub-user-read-update-path' into dev Human 10 days ago
sha256:b7be56ec091919a612cffe7f3c8b600209d5155517443fdac0e16954c8c7d81f Merge 'task/git-export-ignored-file-deletion' into 'dev' — … Human 13 days ago
sha256:8de4334a98c945aace420969d389ad678aa926d4ab4e886b2ac4c4241cb3bf2b revert: keep pyproject.toml in canonical PEP 440 form Sonnet 4.6 patch 16 days ago
sha256:50bb615c573aa4b928fca75b60f82ba2a659910066507cec6a95c412ae22ccb9 docs: add issue docs for push have-negotiation bug (#55) an… Sonnet 4.6 19 days ago
sha256:d90d175cded68aae1d4ffcf4858917854195d0cd8ce1fe73cee4dbc02541cb74 chore: trigger push to surface null-OID paths Sonnet 4.6 25 days ago
sha256:e452ad9a6ace6ccc6d875a35e06caf9da5576a970c1c36133b69a891ce5fefa8 chore: prebuild timing test Sonnet 4.6 35 days ago
sha256:0008ab6695e3e064b3e236b24fd19e538fef6a588eb0d211622f4466d919c0b1 merge: pull staging/dev — advance to 0.2.0rc12 Sonnet 4.6 patch 37 days ago
sha256:9c33d61749fff814c5226d5386aa2af7064c2c02788594a25fdd709358132eea fix: _PROPOSAL_PREFIX_RESOLVE_LIMIT 200 → 100 to match hub … Sonnet 4.6 48 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 51 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 57 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 58 days ago