Files
RoleForge/app/services/yamllint_rules.py
Sergey Antropoff 1d2301fb09 first commit
2026-04-30 08:59:31 +03:00

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)