gabriel / muse public
attributes.py python
674 lines 24.0 KB
Raw
sha256:61100cca63d948098d334e1b600d8fea514568a1c26ef357bf8b6380fbd8a217 chore(timeline): remove unused RationalRate import in entity.py Human minor ⚠ breaking 8 days ago
1 """muse attributes — query and display ``.museattributes`` merge-strategy rules.
2
3 Reads, validates, and pretty-prints the ``.museattributes`` file from the
4 current repository. Supports tabular display, path-strategy resolution, and
5 file validation via three subcommands.
6
7 Subcommands
8 -----------
9
10 ``muse attributes list``
11 Pretty-print all rules in a table showing path pattern, dimension,
12 strategy, priority, and comment. Optionally filter by ``--strategy``
13 or ``--dimension``. This is the **default** when no subcommand is given.
14
15 ``muse attributes check PATH [PATH ...]``
16 Resolve the merge strategy for one or more workspace-relative paths,
17 optionally filtered by dimension. Use ``--match-required`` to exit 1
18 when any path falls back to the default ``auto`` strategy.
19
20 ``muse attributes validate``
21 Validate the ``.museattributes`` file for TOML syntax errors, missing
22 required fields, and unknown strategy values.
23
24 Security model
25 --------------
26 - All user-controlled values read from ``.museattributes`` (domain, path
27 patterns, dimensions, strategies, comments) are passed through
28 ``sanitize_display()`` before appearing in human-readable output.
29 - TOML parse errors and strategy validation errors are reported to **stderr**
30 without printing a raw traceback.
31 - ``_parse_raw`` enforces a 1 MiB file-size cap, preventing OOM from a
32 crafted or corrupted file.
33 - Null bytes in paths supplied to ``check`` are rejected with a clear error.
34
35 Agent UX
36 --------
37 Every subcommand accepts ``--json`` for machine-readable output on **stdout**.
38 All diagnostic and error messages go to **stderr** so that JSON consumers
39 never see noise on stdout.
40
41 JSON schemas
42 ------------
43
44 ``muse attributes list --json``::
45
46 {
47 "domain": "midi", // always present; empty string when unset
48 "rule_count": 1, // total rules after filtering
49 "rules": [
50 {
51 "path_pattern": "drums/*",
52 "dimension": "*",
53 "strategy": "ours",
54 "comment": "Drums are always authored by branch A.",
55 "priority": 10,
56 "source_index": 0
57 }
58 ]
59 }
60
61 ``muse attributes check --json``::
62
63 {
64 "results": [
65 {
66 "path": "drums/kick.mid",
67 "dimension": "*",
68 "strategy": "ours",
69 "rule_index": 0
70 }
71 ]
72 }
73
74 ``muse attributes validate --json``::
75
76 {
77 "valid": true,
78 "rule_count": 3,
79 "errors": []
80 }
81
82 Exit codes
83 ----------
84 - 0 — success
85 - 1 — user error (bad args, invalid strategy, TOML parse error, invalid path)
86 - 2 — not inside a Muse repository
87 """
88
89 import argparse
90 import fnmatch
91 import json
92 import sys
93 from typing import TYPE_CHECKING, TypedDict
94
95 from muse.core.attributes import (
96 AttributeRule,
97 AttributesMeta,
98 load_attributes_full,
99 )
100 from muse.core.envelope import EnvelopeJson, make_envelope
101 from muse.core.errors import ExitCode
102 from muse.core.repo import require_repo
103 from muse.core.timing import start_timer
104 from muse.core.validation import sanitize_display
105
106 # ---------------------------------------------------------------------------
107 # JSON TypedDicts — stable, machine-readable output schemas
108 # ---------------------------------------------------------------------------
109
110 class _RuleJson(TypedDict):
111 """JSON representation of a single ``.museattributes`` rule."""
112
113 path_pattern: str
114 dimension: str
115 strategy: str
116 comment: str
117 priority: int
118 source_index: int
119
120 class _ListJson(EnvelopeJson):
121 """JSON output of ``muse attributes list``.
122
123 ``rule_count`` reflects the number of rules after any ``--strategy`` or
124 ``--dimension`` filters have been applied — it equals ``len(rules)``.
125 """
126
127 domain: str
128 rule_count: int
129 rules: list[_RuleJson]
130
131 class _CheckResultJson(TypedDict):
132 """JSON result for a single path resolution."""
133
134 path: str
135 dimension: str
136 strategy: str
137 rule_index: int # -1 when no rule matched (default "auto")
138
139 class _CheckJson(EnvelopeJson):
140 """JSON output of ``muse attributes check``."""
141
142 results: list[_CheckResultJson]
143
144 class _ValidateErrorJson(TypedDict):
145 """A single validation error entry."""
146
147 kind: str # "syntax" | "semantic" | "missing"
148 message: str
149
150 class _ValidateJson(EnvelopeJson):
151 """JSON output of ``muse attributes validate``.
152
153 ``rule_count`` is the number of rules successfully parsed. It is 0 when
154 ``valid`` is ``false`` (parse failed before rules could be counted).
155 """
156
157 valid: bool
158 rule_count: int
159 errors: list[_ValidateErrorJson]
160
161 # ---------------------------------------------------------------------------
162 # Internal helpers
163 # ---------------------------------------------------------------------------
164
165 def _resolve_with_index(
166 rules: list[AttributeRule],
167 path: str,
168 dimension: str,
169 ) -> tuple[str, int]:
170 """Return ``(strategy, rule.source_index)`` for *path* / *dimension*.
171
172 Implements the same first-match semantics as :func:`resolve_strategy` but
173 also returns which rule was matched so callers can explain the decision.
174 Returns ``("auto", -1)`` when no rule matches.
175
176 Args:
177 rules: Priority-sorted rule list from :func:`load_attributes_full`.
178 path: Workspace-relative POSIX path.
179 dimension: Domain axis name or ``"*"`` to match any dimension.
180 """
181 for rule in rules:
182 path_match = fnmatch.fnmatch(path, rule.path_pattern)
183 dim_match = (
184 rule.dimension == "*"
185 or rule.dimension == dimension
186 or dimension == "*"
187 )
188 if path_match and dim_match:
189 return rule.strategy, rule.source_index
190 return "auto", -1
191
192 def _rule_to_json(rule: AttributeRule) -> _RuleJson:
193 """Convert an :class:`AttributeRule` to its JSON TypedDict form."""
194 return _RuleJson(
195 path_pattern=rule.path_pattern,
196 dimension=rule.dimension,
197 strategy=rule.strategy,
198 comment=rule.comment,
199 priority=rule.priority,
200 source_index=rule.source_index,
201 )
202
203 # ---------------------------------------------------------------------------
204 # Command registration
205 # ---------------------------------------------------------------------------
206
207 def register(
208 subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]",
209 ) -> None:
210 """Register the ``attributes`` subcommand tree and all its flags.
211
212 Every subcommand accepts ``--json`` for machine-readable output on stdout.
213 All diagnostic messages go to stderr.
214 """
215 parser = subparsers.add_parser(
216 "attributes",
217 help="Query and display .museattributes merge-strategy rules.",
218 description=__doc__,
219 formatter_class=argparse.RawDescriptionHelpFormatter,
220 )
221 subs = parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND")
222 subs.required = True
223
224 # ── check ─────────────────────────────────────────────────────────────
225 check_p = subs.add_parser(
226 "check",
227 help="Resolve the merge strategy for one or more workspace-relative paths.",
228 description=(
229 "Apply ``.museattributes`` rule matching to one or more paths and\n"
230 "print the resolved strategy for each. Uses first-match semantics:\n"
231 "rules are evaluated in descending priority order; the first rule\n"
232 "whose path glob and dimension both match wins.\n\n"
233 "``rule_index`` in the output identifies which ``[[rules]]`` entry\n"
234 "matched (0-based source order); ``-1`` means no rule matched and\n"
235 "the default ``auto`` strategy applies. Use ``-d`` / ``--dimension``\n"
236 "to filter by a specific domain axis.\n\n"
237 "Agent quickstart\n"
238 "----------------\n"
239 " muse attributes check src/foo.py --json\n"
240 " muse attributes check src/foo.py -j\n"
241 " muse attributes check a.mid b.mid -d pitch_bend --json\n"
242 " muse attributes check src/foo.py --match-required # exit 1 if no rule matched\n\n"
243 "JSON output schema\n"
244 "------------------\n"
245 ' {"results": [{"path": "<str>", "dimension": "<str>",\n'
246 ' "strategy": "<str>", "rule_index": <int>}, ...]}\n\n'
247 "Exit codes\n"
248 "----------\n"
249 " 0 — all paths resolved (no-match defaults are still success)\n"
250 " 1 — null byte in a path arg, TOML parse error, or unknown strategy\n"
251 " 2 — not inside a Muse repository\n"
252 ),
253 formatter_class=argparse.RawDescriptionHelpFormatter,
254 )
255 check_p.add_argument(
256 "paths",
257 nargs="+",
258 metavar="PATH",
259 help="Workspace-relative path(s) to resolve.",
260 )
261 check_p.add_argument(
262 "--dimension",
263 "-d",
264 default="*",
265 metavar="DIM",
266 help="Domain dimension to match against (default: '*' matches any).",
267 )
268 check_p.add_argument(
269 "--match-required",
270 action="store_true",
271 dest="match_required",
272 default=False,
273 help=(
274 "Exit 1 if any path resolves to the default 'auto' strategy "
275 "(i.e. no rule matched). Useful in CI or agent scripts that "
276 "require every path to have an explicit rule."
277 ),
278 )
279 check_p.add_argument(
280 "--json", "-j",
281 action="store_true",
282 dest="json_out",
283 default=False,
284 help="Emit a JSON object with a 'results' array to stdout.",
285 )
286 check_p.set_defaults(func=run_check)
287
288 # ── list ──────────────────────────────────────────────────────────────
289 list_p = subs.add_parser(
290 "list",
291 help="Pretty-print all rules (path pattern, dimension, strategy, priority, comment).",
292 description=(
293 "Parse ``.museattributes`` and display every rule as an aligned\n"
294 "table showing path pattern, dimension, strategy, priority, and\n"
295 "comment. An optional priority column appears only when at least\n"
296 "one rule has a non-zero priority. A comment column appears only\n"
297 "when at least one rule carries a comment.\n\n"
298 "Agent quickstart\n"
299 "----------------\n"
300 " muse attributes list --json\n"
301 " muse attributes list -j\n"
302 " muse attributes list --strategy ours --json\n"
303 " muse attributes list --dimension notes --json\n"
304 " DOMAIN=$(muse attributes list --json | jq -r .domain)\n\n"
305 "JSON output schema\n"
306 "------------------\n"
307 ' {"domain": "<str>", "rule_count": <int>,\n'
308 ' "rules": [{"path_pattern": "<glob>", "dimension": "<str>",\n'
309 ' "strategy": "<str>", "comment": "<str>",\n'
310 ' "priority": <int>, "source_index": <int>}, ...]}\n\n'
311 "Exit codes\n"
312 "----------\n"
313 " 0 — success (empty rules list is still success)\n"
314 " 1 — TOML parse error or unknown strategy\n"
315 " 2 — not inside a Muse repository\n"
316 ),
317 formatter_class=argparse.RawDescriptionHelpFormatter,
318 )
319 list_p.add_argument(
320 "--strategy", "-s",
321 default=None,
322 metavar="STRATEGY",
323 help="Filter output to rules with this strategy value (e.g. ours, theirs).",
324 )
325 list_p.add_argument(
326 "--dimension", "-d",
327 default=None,
328 metavar="DIM",
329 help="Filter output to rules with this dimension value (e.g. notes, *).",
330 )
331 list_p.add_argument(
332 "--json", "-j",
333 action="store_true",
334 dest="json_out",
335 default=False,
336 help="Emit a JSON object to stdout with domain, rule_count, and rules array.",
337 )
338 list_p.set_defaults(func=run_list)
339
340 # ── validate ──────────────────────────────────────────────────────────
341 validate_p = subs.add_parser(
342 "validate",
343 help="Validate .museattributes for TOML syntax and semantic correctness.",
344 description=(
345 "Parse ``.museattributes`` and check for TOML syntax errors,\n"
346 "missing required rule fields, unknown strategy values, and the\n"
347 "1 MiB file-size limit. Exits 0 only when the file is fully\n"
348 "valid; exits 1 for any validation failure.\n\n"
349 "In text mode, prints a one-line summary with the rule count and\n"
350 "domain on success, or an error message on failure.\n\n"
351 "Agent quickstart\n"
352 "----------------\n"
353 " muse attributes validate --json\n"
354 " muse attributes validate -j\n"
355 " muse attributes validate --json | jq .valid\n"
356 " muse attributes validate --json | jq '.errors[].kind'\n\n"
357 "JSON output schema\n"
358 "------------------\n"
359 ' {"valid": <bool>,\n'
360 ' "errors": [{"kind": "missing"|"syntax"|"semantic",\n'
361 ' "message": "<str>"}, ...]}\n\n'
362 "Error kinds\n"
363 "-----------\n"
364 ' "missing" — .museattributes does not exist\n'
365 ' "semantic" — TOML parsed but a rule field is invalid\n\n'
366 "Exit codes\n"
367 "----------\n"
368 " 0 — file is valid\n"
369 " 1 — file is missing, malformed, or contains unknown strategies\n"
370 " 2 — not inside a Muse repository\n"
371 ),
372 formatter_class=argparse.RawDescriptionHelpFormatter,
373 )
374 validate_p.add_argument(
375 "--json", "-j",
376 action="store_true",
377 dest="json_out",
378 default=False,
379 help="Emit a JSON object with 'valid' and 'errors' fields to stdout.",
380 )
381 validate_p.set_defaults(func=run_validate)
382
383 # ---------------------------------------------------------------------------
384 # Subcommand handlers
385 # ---------------------------------------------------------------------------
386
387 def run_list(args: argparse.Namespace) -> None:
388 """Display all ``.museattributes`` rules in tabular or JSON format.
389
390 Parses the file, applies optional ``--strategy`` and ``--dimension`` filters,
391 then prints an aligned table or JSON object. An empty result after filtering
392 is still exit 0. All user-controlled values are sanitized before text output;
393 raw values are preserved verbatim in JSON.
394
395 Agent quickstart
396 ----------------
397 ::
398
399 muse attributes list --json
400 muse attributes list --strategy ours --json
401 muse attributes list --dimension notes --json
402
403 JSON fields
404 -----------
405 domain Repository domain from the ``[meta]`` section; empty string if unset.
406 rule_count Total rules after filtering — equals ``len(rules)``.
407 rules List of rule objects sorted by descending priority.
408
409 Each rule:
410
411 path_pattern Glob matched against workspace-relative POSIX paths.
412 dimension Domain axis name, or ``"*"`` for any.
413 strategy Merge strategy (e.g. ``"ours"``, ``"theirs"``, ``"auto"``).
414 comment Optional annotation from the file.
415 priority Integer priority; higher values take precedence.
416 source_index Zero-based declaration order in the file.
417
418 Exit codes
419 ----------
420 0 Success (empty rule list is still success).
421 1 TOML parse error or unknown strategy.
422 2 Not inside a Muse repository.
423 """
424 elapsed = start_timer()
425 json_out: bool = args.json_out
426 filter_strategy: str | None = args.strategy
427 filter_dimension: str | None = args.dimension
428
429 root = require_repo()
430 try:
431 meta, rules = load_attributes_full(root)
432 except ValueError as exc:
433 print(f"❌ {exc}", file=sys.stderr)
434 raise SystemExit(ExitCode.USER_ERROR.value) from exc
435
436 # Apply optional filters.
437 if filter_strategy is not None:
438 rules = [r for r in rules if r.strategy == filter_strategy]
439 if filter_dimension is not None:
440 rules = [r for r in rules if r.dimension == filter_dimension]
441
442 domain_raw: str = meta.get("domain") or ""
443
444 if json_out:
445 print(json.dumps(_ListJson(
446 **make_envelope(elapsed),
447 domain=domain_raw,
448 rule_count=len(rules),
449 rules=[_rule_to_json(r) for r in rules],
450 )))
451 return
452
453 if not rules:
454 attr_file = root / ".museattributes"
455 if not attr_file.exists():
456 print(
457 "No .museattributes file found.",
458 file=sys.stderr,
459 )
460 else:
461 print(
462 ".museattributes is present but contains no rules.",
463 file=sys.stderr,
464 )
465 print(
466 "Create one at the repository root to declare per-path merge strategies.",
467 file=sys.stderr,
468 )
469 return
470
471 if domain_raw:
472 print(f"Domain: {sanitize_display(domain_raw)}")
473 print()
474
475 has_comments = any(r.comment for r in rules)
476 has_nonzero_priority = any(r.priority != 0 for r in rules)
477
478 pat_w = max(len("Path pattern"), max(len(r.path_pattern) for r in rules))
479 dim_w = max(len("Dimension"), max(len(r.dimension) for r in rules))
480 pri_w = max(len("Pri"), max(len(str(r.priority)) for r in rules))
481
482 header_parts = [
483 f"{'Path pattern':<{pat_w}}",
484 f"{'Dimension':<{dim_w}}",
485 f"{'Pri':>{pri_w}}",
486 "Strategy",
487 ]
488 sep_parts = ["-" * pat_w, "-" * dim_w, "-" * pri_w, "--------"]
489 if has_comments:
490 header_parts.append("Comment")
491 sep_parts.append("-------")
492
493 print(" ".join(header_parts))
494 print(" ".join(sep_parts))
495
496 for rule in rules:
497 pat = sanitize_display(rule.path_pattern)
498 dim = sanitize_display(rule.dimension)
499 strat = sanitize_display(rule.strategy)
500 pri_str = str(rule.priority) if has_nonzero_priority else ""
501 line_parts = [
502 f"{pat:<{pat_w}}",
503 f"{dim:<{dim_w}}",
504 f"{pri_str:>{pri_w}}",
505 strat,
506 ]
507 if has_comments:
508 comment = sanitize_display(rule.comment)
509 line_parts.append(comment)
510 print(" ".join(line_parts))
511
512 def run_check(args: argparse.Namespace) -> None:
513 """Resolve the merge strategy for each given path.
514
515 Uses first-match rule semantics (same as the merge engine). Returns
516 ``rule_index`` (0-based declaration order) so results can be correlated back
517 to ``muse attributes list`` output. A ``rule_index`` of ``-1`` means no rule
518 matched and the default ``"auto"`` strategy applies.
519
520 Agent quickstart
521 ----------------
522 ::
523
524 muse attributes check src/foo.py --json
525 muse attributes check a.mid b.mid --dimension pitch_bend --json
526 muse attributes check src/foo.py --match-required --json
527
528 JSON fields
529 -----------
530 results List of one result per input path, in argument order.
531
532 Each result:
533
534 path Input path string (verbatim).
535 dimension Dimension used for resolution (echoed from ``-d``; default ``"*"``).
536 strategy Resolved strategy: ``"ours"``, ``"theirs"``, ``"auto"``, etc.
537 rule_index 0-based source index of the matched rule; ``-1`` if no rule matched.
538
539 Exit codes
540 ----------
541 0 All paths resolved (no-match defaults are still success).
542 1 Null byte in path, TOML error, unknown strategy, or ``--match-required`` triggered.
543 2 Not inside a Muse repository.
544 """
545 elapsed = start_timer()
546 json_out: bool = args.json_out
547 paths: list[str] = args.paths
548 dimension: str = args.dimension
549 match_required: bool = args.match_required
550
551 root = require_repo()
552 try:
553 _, rules = load_attributes_full(root)
554 except ValueError as exc:
555 print(f"❌ {exc}", file=sys.stderr)
556 raise SystemExit(ExitCode.USER_ERROR.value) from exc
557
558 results: list[_CheckResultJson] = []
559 for raw_path in paths:
560 if "\x00" in raw_path:
561 print(f"❌ null byte in path: {raw_path!r}", file=sys.stderr)
562 raise SystemExit(ExitCode.USER_ERROR.value)
563 strategy, rule_idx = _resolve_with_index(rules, raw_path, dimension)
564 results.append(
565 _CheckResultJson(
566 path=raw_path,
567 dimension=dimension,
568 strategy=strategy,
569 rule_index=rule_idx,
570 )
571 )
572
573 unmatched = [r for r in results if r["rule_index"] == -1]
574
575 if json_out:
576 print(json.dumps(_CheckJson(**make_envelope(elapsed), results=results)))
577 if match_required and unmatched:
578 for r in unmatched:
579 print(
580 f"❌ no rule matched: {sanitize_display(r['path'])}",
581 file=sys.stderr,
582 )
583 raise SystemExit(ExitCode.USER_ERROR.value)
584 return
585
586 for item in results:
587 path_disp = sanitize_display(item["path"])
588 strat_disp = sanitize_display(item["strategy"])
589 if item["rule_index"] >= 0:
590 rule_note = f" (rule #{item['rule_index']})"
591 else:
592 rule_note = " (default)"
593 print(f"{path_disp}: {strat_disp}{rule_note}")
594
595 if match_required and unmatched:
596 for r in unmatched:
597 print(
598 f"❌ no rule matched: {sanitize_display(r['path'])}",
599 file=sys.stderr,
600 )
601 raise SystemExit(ExitCode.USER_ERROR.value)
602
603 def run_validate(args: argparse.Namespace) -> None:
604 """Validate ``.museattributes`` for syntax and semantic correctness.
605
606 Checks: file exists, TOML is valid and within 1 MiB, all rules carry
607 required fields (``path``, ``dimension``, ``strategy``), and every strategy
608 value is recognised. Exits 0 only when all checks pass.
609
610 Agent quickstart
611 ----------------
612 ::
613
614 muse attributes validate --json
615 muse attributes validate --json | python3 -c "import sys,json; print(json.load(sys.stdin)['valid'])"
616
617 JSON fields
618 -----------
619 valid ``true`` when all checks pass; ``false`` otherwise.
620 rule_count Number of rules parsed; ``0`` when ``valid`` is ``false``.
621 errors List of error objects (empty on success).
622
623 Each error:
624
625 kind ``"missing"`` (file absent) or ``"semantic"`` (rule field invalid).
626 message Human-readable description of the error.
627
628 Exit codes
629 ----------
630 0 File is valid.
631 1 File missing, malformed, or contains unknown strategies.
632 2 Not inside a Muse repository.
633 """
634 elapsed = start_timer()
635 json_out: bool = args.json_out
636
637 root = require_repo()
638 attr_file = root / ".museattributes"
639
640 errors: list[_ValidateErrorJson] = []
641
642 if not attr_file.exists():
643 errors.append(
644 _ValidateErrorJson(
645 kind="missing",
646 message=".museattributes file does not exist",
647 )
648 )
649 if json_out:
650 print(json.dumps(_ValidateJson(**make_envelope(elapsed), valid=False, rule_count=0, errors=errors)))
651 else:
652 print("❌ .museattributes file does not exist.", file=sys.stderr)
653 raise SystemExit(ExitCode.USER_ERROR.value)
654
655 try:
656 meta, rules = load_attributes_full(root)
657 except ValueError as exc:
658 errors.append(_ValidateErrorJson(kind="semantic", message=str(exc)))
659 if json_out:
660 print(json.dumps(_ValidateJson(**make_envelope(elapsed), valid=False, rule_count=0, errors=errors)))
661 else:
662 print(f"❌ {errors[0]['message']}", file=sys.stderr)
663 raise SystemExit(ExitCode.USER_ERROR.value) from exc
664
665 if json_out:
666 print(json.dumps(_ValidateJson(**make_envelope(elapsed), valid=True, rule_count=len(rules), errors=[])))
667 return
668
669 domain_raw = meta.get("domain") or ""
670 domain_disp = sanitize_display(domain_raw) if domain_raw else "(not set)"
671 print(
672 f"✅ .museattributes is valid — {len(rules)} rule(s), "
673 f"domain: {domain_disp}"
674 )
File History 1 commit
sha256:61100cca63d948098d334e1b600d8fea514568a1c26ef357bf8b6380fbd8a217 chore(timeline): remove unused RationalRate import in entity.py Human minor 8 days ago