174 lines
7.2 KiB
Python
174 lines
7.2 KiB
Python
"""yamllint rule definitions for RoleForge admin Lint config UI and runtime YAML generation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
from typing import Any
|
|
|
|
# Order shown in the admin UI (yamllint built-in rules compatible with `extends: default`).
|
|
YAMLLINT_RULE_IDS: tuple[str, ...] = (
|
|
"braces",
|
|
"brackets",
|
|
"colons",
|
|
"commas",
|
|
"comments",
|
|
"comments-indentation",
|
|
"document-end",
|
|
"document-start",
|
|
"empty-lines",
|
|
"empty-values",
|
|
"hyphens",
|
|
"indentation",
|
|
"key-duplicates",
|
|
"key-ordering",
|
|
"line-length",
|
|
"new-line-at-end-of-file",
|
|
"new-lines",
|
|
"octal-values",
|
|
"quoted-strings",
|
|
"trailing-spaces",
|
|
"truthy",
|
|
)
|
|
|
|
RULE_META: tuple[dict[str, Any], ...] = (
|
|
{"id": "braces", "title": "Braces", "hint": "Flow mapping braces { } usage."},
|
|
{"id": "brackets", "title": "Brackets", "hint": "Flow sequence brackets [ ] usage."},
|
|
{"id": "colons", "title": "Colons", "hint": "Spacing after colons in mappings."},
|
|
{"id": "commas", "title": "Commas", "hint": "Commas in flow collections."},
|
|
{"id": "comments", "title": "Comments", "hint": "Comment indentation and placement."},
|
|
{"id": "comments-indentation", "title": "Comment indentation", "hint": "Indentation of comments relative to content."},
|
|
{"id": "document-end", "title": "Document end marker", "hint": "YAML document end (…) marker rules."},
|
|
{"id": "document-start", "title": "Document start marker", "hint": "YAML document start (…) marker rules."},
|
|
{"id": "empty-lines", "title": "Empty lines", "hint": "Too many or misplaced blank lines."},
|
|
{"id": "empty-values", "title": "Empty values", "hint": "Explicit empty values in mappings."},
|
|
{"id": "hyphens", "title": "Hyphens", "hint": "Block sequence hyphen spacing."},
|
|
{"id": "indentation", "title": "Indentation", "hint": "Spaces vs indentation width."},
|
|
{"id": "key-duplicates", "title": "Duplicate keys", "hint": "Duplicate keys in the same mapping."},
|
|
{"id": "key-ordering", "title": "Key ordering", "hint": "Alphabetical key order (style)."},
|
|
{
|
|
"id": "line-length",
|
|
"title": "Line length",
|
|
"hint": "Long lines; default max is 80 in yamllint — RoleForge default 160 for Ansible.",
|
|
},
|
|
{"id": "new-line-at-end-of-file", "title": "Newline at end of file", "hint": "File should end with a line break."},
|
|
{"id": "new-lines", "title": "New lines", "hint": "Line ending type (LF vs CRLF) consistency."},
|
|
{"id": "octal-values", "title": "Octal values", "hint": "Questionable octal integer forms."},
|
|
{"id": "quoted-strings", "title": "Quoted strings", "hint": "When strings should be quoted."},
|
|
{"id": "trailing-spaces", "title": "Trailing spaces", "hint": "Whitespace at end of lines."},
|
|
{"id": "truthy", "title": "Truthy values", "hint": "true/false vs yes/no style for booleans."},
|
|
)
|
|
|
|
|
|
def _default_state_for_rule(rule_id: str) -> dict[str, Any]:
|
|
st: dict[str, Any] = {"enabled": True, "blocking": True}
|
|
if rule_id == "line-length":
|
|
st["max"] = 160
|
|
if rule_id == "indentation":
|
|
st["spaces"] = 2
|
|
return st
|
|
|
|
|
|
def default_merged_rules() -> dict[str, dict[str, Any]]:
|
|
return {rid: _default_state_for_rule(rid) for rid in YAMLLINT_RULE_IDS}
|
|
|
|
|
|
def merge_yamllint_saved(saved: dict[str, Any] | None) -> dict[str, dict[str, Any]]:
|
|
"""Overlay DB `saved` onto defaults; unknown keys ignored."""
|
|
base = default_merged_rules()
|
|
if not saved or not isinstance(saved, dict):
|
|
return base
|
|
rules_in = saved.get("rules")
|
|
if not isinstance(rules_in, dict):
|
|
return base
|
|
for rid, patch in rules_in.items():
|
|
if rid not in base or not isinstance(patch, dict):
|
|
continue
|
|
if "enabled" in patch:
|
|
base[rid]["enabled"] = bool(patch["enabled"])
|
|
if "blocking" in patch:
|
|
base[rid]["blocking"] = bool(patch["blocking"])
|
|
if rid == "line-length" and "max" in patch:
|
|
try:
|
|
mx = int(patch["max"])
|
|
base[rid]["max"] = max(40, min(500, mx))
|
|
except (TypeError, ValueError):
|
|
pass
|
|
if rid == "indentation" and "spaces" in patch:
|
|
try:
|
|
sp = int(patch["spaces"])
|
|
base[rid]["spaces"] = max(1, min(16, sp))
|
|
except (TypeError, ValueError):
|
|
pass
|
|
return base
|
|
|
|
|
|
def rules_to_yamllint_yaml(merged: dict[str, dict[str, Any]]) -> str:
|
|
"""Build a yamllint config string: extends default + explicit rules (editor uses error vs warning for “blocking”)."""
|
|
lines: list[str] = ["extends: default", "rules:"]
|
|
for rid in YAMLLINT_RULE_IDS:
|
|
st = merged.get(rid) or _default_state_for_rule(rid)
|
|
enabled = bool(st.get("enabled", True))
|
|
if not enabled:
|
|
lines.append(f" {rid}: disable")
|
|
continue
|
|
blocking = bool(st.get("blocking", True))
|
|
level = "error" if blocking else "warning"
|
|
if rid == "line-length":
|
|
mx = int(st.get("max") or 160)
|
|
mx = max(40, min(500, mx))
|
|
lines.append(f" {rid}:")
|
|
lines.append(f" max: {mx}")
|
|
lines.append(f" level: {level}")
|
|
elif rid == "indentation":
|
|
sp = int(st.get("spaces") or 2)
|
|
sp = max(1, min(16, sp))
|
|
lines.append(f" {rid}:")
|
|
lines.append(f" spaces: {sp}")
|
|
lines.append(f" level: {level}")
|
|
else:
|
|
lines.append(f" {rid}:")
|
|
lines.append(f" level: {level}")
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def validate_put_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
|
"""Validate admin PUT body; returns full `rules` snapshot for JSONB storage."""
|
|
if not isinstance(payload, dict):
|
|
raise ValueError("Payload must be an object")
|
|
raw_rules = payload.get("rules")
|
|
if not isinstance(raw_rules, dict):
|
|
raise ValueError("`rules` must be an object")
|
|
merged = default_merged_rules()
|
|
for rid in YAMLLINT_RULE_IDS:
|
|
patch = raw_rules.get(rid)
|
|
if patch is None:
|
|
continue
|
|
if not isinstance(patch, dict):
|
|
raise ValueError(f"Rule {rid} must be an object")
|
|
if "enabled" in patch:
|
|
merged[rid]["enabled"] = bool(patch["enabled"])
|
|
if "blocking" in patch:
|
|
merged[rid]["blocking"] = bool(patch["blocking"])
|
|
if rid == "line-length" and "max" in patch:
|
|
try:
|
|
mx = int(patch["max"])
|
|
merged[rid]["max"] = max(40, min(500, mx))
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValueError("`line-length.max` must be an integer") from exc
|
|
if rid == "indentation" and "spaces" in patch:
|
|
try:
|
|
sp = int(patch["spaces"])
|
|
merged[rid]["spaces"] = max(1, min(16, sp))
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValueError("`indentation.spaces` must be an integer") from exc
|
|
# reject unknown keys
|
|
for rid in raw_rules:
|
|
if rid not in YAMLLINT_RULE_IDS:
|
|
raise ValueError(f"Unknown rule: {rid}")
|
|
return {"rules": merged}
|
|
|
|
|
|
def serialize_rules_for_api(merged: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
|
"""Return deep copy suitable for JSON (GET response)."""
|
|
return copy.deepcopy(merged)
|