Files
the-other-dude/backend/app/services/config_change_parser.py
Jason Staack 06a41ca9bf fix(lint): resolve all ruff lint errors
Add ruff config to exclude alembic E402, SQLAlchemy F821, and pre-existing
E501 line-length issues. Auto-fix 69 unused imports and 2 f-strings without
placeholders. Manually fix 8 unused variables. Apply ruff format to 127 files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 22:17:50 -05:00

120 lines
3.8 KiB
Python

"""Structured change parser for RouterOS unified diffs.
Extracts component names and human-readable summaries from unified diffs
of RouterOS configurations. Each distinct RouterOS section in the diff
produces one change entry with component path, summary, and raw diff lines.
"""
from __future__ import annotations
import re
from collections import defaultdict
def _path_to_component(path: str) -> str:
"""Convert RouterOS path to component format.
'/ip firewall filter' -> 'ip/firewall/filter'
"""
return path.strip().lstrip("/").replace(" ", "/")
def _component_label(component: str) -> str:
"""Extract human-readable label from component path.
'ip/firewall/filter' -> 'firewall filter'
Last two segments joined with space, or last segment if only one.
"""
parts = component.split("/")
if len(parts) >= 2:
return " ".join(parts[-2:])
return parts[-1]
def _make_summary(adds: int, removes: int, component: str) -> str:
"""Generate human-readable summary for a component's changes."""
label = _component_label(component)
if adds > 0 and removes > 0:
n = max(adds, removes)
noun = "rule" if n == 1 else "rules"
return f"Modified {n} {label} {noun}"
elif adds > 0:
noun = "rule" if adds == 1 else "rules"
return f"Added {adds} {label} {noun}"
else:
noun = "rule" if removes == 1 else "rules"
return f"Removed {removes} {label} {noun}"
_ROUTEROS_PATH_RE = re.compile(r"^(/[a-z][a-z0-9 /\-]*)", re.IGNORECASE)
FALLBACK_COMPONENT = "system/general"
def parse_diff_changes(diff_text: str) -> list[dict]:
"""Parse a unified diff of RouterOS config into structured changes.
Returns list of dicts sorted by component, one per distinct component:
{"component": "ip/firewall/filter",
"summary": "Added 2 firewall filter rules",
"raw_line": "+add chain=..."}
"""
lines = diff_text.splitlines()
current_section: str | None = None
# Track per-component: adds, removes, raw_lines
components: dict[str, dict] = defaultdict(lambda: {"adds": 0, "removes": 0, "raw_lines": []})
for line in lines:
# Skip unified diff headers
if line.startswith("---") or line.startswith("+++") or line.startswith("@@"):
continue
# Determine the raw content (strip the diff prefix for path detection)
if line.startswith("+") or line.startswith("-"):
content = line[1:]
is_change = True
is_add = line.startswith("+")
elif line.startswith(" "):
content = line[1:]
is_change = False
is_add = False
else:
content = line
is_change = False
is_add = False
# Check if content contains a RouterOS path header
path_match = _ROUTEROS_PATH_RE.match(content.strip())
if path_match:
candidate = path_match.group(1).strip()
# Must have at least 2 chars after / to be a real path
if len(candidate) > 1:
current_section = _path_to_component(candidate)
# Record changed lines
if is_change:
section = current_section or FALLBACK_COMPONENT
components[section]["raw_lines"].append(line)
if is_add:
components[section]["adds"] += 1
else:
components[section]["removes"] += 1
# Build result list
result = []
for component in sorted(components.keys()):
data = components[component]
if data["adds"] == 0 and data["removes"] == 0:
continue
result.append(
{
"component": component,
"summary": _make_summary(data["adds"], data["removes"], component),
"raw_line": "\n".join(data["raw_lines"]),
}
)
return result