429 lines
16 KiB
Python
429 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""Regenerate the action tables in docs/services/*.md from handler source.
|
|
|
|
The action LIST is derived from handler source (never hand-edited, never drifts).
|
|
The Description column is hand-written prose: regen PRESERVES existing descriptions
|
|
keyed by action name and leaves a "-" placeholder for new/unknown actions. The
|
|
generator only ever rewrites the bytes between the marker pair; everything else in
|
|
the file is byte-identical pre and post.
|
|
|
|
Assumption: a switch `case` label counts as supported purely by existing, so an arm
|
|
that is present but throws `UnsupportedOperation` in its body would still be listed.
|
|
Handlers here reject unsupported actions from the `default` arm, so this holds; a
|
|
per-action stub that throws would be a false positive.
|
|
|
|
Run from anywhere in the repo:
|
|
python3 tools/docs/regen_action_docs.py # rewrite docs in place
|
|
python3 tools/docs/regen_action_docs.py --strict # exit non-zero on warnings
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import re
|
|
import sys
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
MARKER_START = "<!-- floci:actions:start -->"
|
|
MARKER_END = "<!-- floci:actions:end -->"
|
|
|
|
PLACEHOLDER = "-"
|
|
|
|
# `case "ActionName" ->` switch arms (Query / JSON 1.1 protocols). A single arm may
|
|
# carry several labels: `case "Foo", "Bar", "Baz" ->`; all are extracted, in order.
|
|
# The label list may also wrap across lines (valid Java 14+ switch syntax), so the
|
|
# capture group excludes only the `{ } ;` statement/body boundaries rather than
|
|
# newlines: it spans line breaks between labels but stops at the arm's `->` and can't
|
|
# run into an arm body or a colon-style `case` that has no `->`. Without this a
|
|
# wrapped arm would be silently dropped, and `docs-check` would stay green because the
|
|
# doc and the tool's output would agree on the omission.
|
|
# The label head requires `[A-Z][a-z]` (PascalCase), which deliberately excludes
|
|
# SCREAMING_CASE enum labels in non-action switches a handler may also contain
|
|
# (e.g. `switch (state) { case "DISABLED" -> ... }`). AWS action names are always
|
|
# PascalCase, so no real action is lost.
|
|
SWITCH_CASE_RE = re.compile(r"^\s*case\s+([^{};]+?)\s*->", re.MULTILINE)
|
|
SWITCH_LABEL_RE = re.compile(r'"([A-Z][a-z][A-Za-z0-9]*)"')
|
|
|
|
# REST controllers: a JAX-RS verb annotation followed by the public method it decorates,
|
|
# inside a class that carries a class-level @Path. The action is ucfirst(methodName),
|
|
# corrected per-service via rename_actions / exclude_actions (method names are not
|
|
# always the canonical AWS action, e.g. updateV2Integration -> UpdateIntegration).
|
|
CLASS_PATH_RE = re.compile(r"^\s*@Path\b", re.MULTILINE)
|
|
HTTP_ANNO_RE = re.compile(r"^\s*@(?:GET|POST|PUT|DELETE|PATCH)\b")
|
|
ANNOTATION_LINE_RE = re.compile(r"^\s*@\w")
|
|
PUBLIC_METHOD_RE = re.compile(r"^\s*public\s+\S+(?:<[^>]*>)?\s+([a-z][A-Za-z0-9]*)\s*\(")
|
|
# Line/block-comment lines (Javadoc) that may sit between the verb annotation and the
|
|
# method signature; skipped so they don't reset the `pending` flag mid-lookahead.
|
|
COMMENT_LINE_RE = re.compile(r"^\s*(?://|/\*|\*)")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Source:
|
|
path: Path
|
|
mode: str # 'switch' | 'rest'
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ServiceEntry:
|
|
service: str
|
|
doc: Path
|
|
sources: list[Source]
|
|
rename_actions: dict[str, str] = field(default_factory=dict)
|
|
exclude_actions: frozenset[str] = field(default_factory=frozenset)
|
|
handler: Path | None = None # optional explicit handler path for excluded services
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Extraction (pure functions over Java source text)
|
|
# --------------------------------------------------------------------------- #
|
|
def extract_switch_actions(java_source: str) -> list[str]:
|
|
"""Action names from `case "X" ->` arms, in source order.
|
|
|
|
Handles multi-label arms (`case "A", "B" ->`) by extracting every quoted
|
|
PascalCase label up to the arm's `->`.
|
|
"""
|
|
out: list[str] = []
|
|
for arm in SWITCH_CASE_RE.finditer(java_source):
|
|
out.extend(m.group(1) for m in SWITCH_LABEL_RE.finditer(arm.group(1)))
|
|
return out
|
|
|
|
|
|
CLASS_EXTENDS_RE = re.compile(r"public\s+(?:abstract\s+)?class\s+\w+\s+extends\s+([A-Za-z0-9_]+)")
|
|
|
|
|
|
def _extract_raw_rest_methods(java_source: str) -> list[str]:
|
|
actions: list[str] = []
|
|
pending = False
|
|
for line in java_source.splitlines():
|
|
if not line.strip():
|
|
continue
|
|
if HTTP_ANNO_RE.match(line):
|
|
pending = True
|
|
continue
|
|
if not pending:
|
|
continue
|
|
if ANNOTATION_LINE_RE.match(line):
|
|
continue
|
|
if COMMENT_LINE_RE.match(line):
|
|
continue
|
|
m = PUBLIC_METHOD_RE.match(line)
|
|
if m:
|
|
name = m.group(1)
|
|
actions.append(name[0].upper() + name[1:])
|
|
pending = False
|
|
return actions
|
|
|
|
|
|
def extract_rest_actions(java_source: str, source_path: Path | None = None) -> list[str]:
|
|
"""ucfirst(method name) of @GET/@POST/@PUT/@DELETE/@PATCH methods, source order.
|
|
|
|
Only applies to classes containing a class-level @Path. Returns [] otherwise.
|
|
Inherited methods from a superclass in the same directory are included first.
|
|
"""
|
|
if not CLASS_PATH_RE.search(java_source):
|
|
return []
|
|
actions: list[str] = []
|
|
if source_path is not None:
|
|
extends_match = CLASS_EXTENDS_RE.search(java_source)
|
|
if extends_match:
|
|
super_name = extends_match.group(1)
|
|
super_path = source_path.parent / f"{super_name}.java"
|
|
if super_path.exists():
|
|
actions.extend(_extract_raw_rest_methods(super_path.read_text(encoding="utf-8")))
|
|
actions.extend(_extract_raw_rest_methods(java_source))
|
|
return actions
|
|
|
|
|
|
def extract_actions(entry: ServiceEntry) -> list[str]:
|
|
"""Merged, deduped, override-corrected action list for a service.
|
|
|
|
Order: first appearance across the source list in registry order. rename_actions
|
|
is applied before dedup (so a renamed collision dedupes correctly); excluded
|
|
names are dropped after rename.
|
|
"""
|
|
seen: set[str] = set()
|
|
out: list[str] = []
|
|
for source in entry.sources:
|
|
text = source.path.read_text(encoding="utf-8")
|
|
if source.mode == "switch":
|
|
raw = extract_switch_actions(text)
|
|
elif source.mode == "rest":
|
|
raw = extract_rest_actions(text, source.path)
|
|
else:
|
|
raise ValueError(f"unknown mode: {source.mode!r}")
|
|
for name in raw:
|
|
name = entry.rename_actions.get(name, name)
|
|
if name in entry.exclude_actions:
|
|
continue
|
|
if name not in seen:
|
|
seen.add(name)
|
|
out.append(name)
|
|
return out
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Markdown marker block: parse existing descriptions, render new table
|
|
# --------------------------------------------------------------------------- #
|
|
_SEPARATOR_ROW_RE = re.compile(r"^\|[\s:|-]+\|$")
|
|
|
|
|
|
def _split_row(row: str) -> list[str]:
|
|
"""Split a markdown table row into trimmed cells, honoring escaped pipes (\\|).
|
|
|
|
A literal pipe inside a cell must be written as \\| in markdown; an unescaped |
|
|
is always a column separator (including pipes inside inline code, which is why
|
|
descriptions should escape them). The split is on unescaped pipes only.
|
|
"""
|
|
cells: list[str] = []
|
|
buf: list[str] = []
|
|
i = 0
|
|
n = len(row)
|
|
while i < n:
|
|
ch = row[i]
|
|
if ch == "\\" and i + 1 < n and row[i + 1] == "|":
|
|
buf.append("|")
|
|
i += 2
|
|
continue
|
|
if ch == "|":
|
|
cells.append("".join(buf).strip())
|
|
buf = []
|
|
i += 1
|
|
continue
|
|
buf.append(ch)
|
|
i += 1
|
|
cells.append("".join(buf).strip())
|
|
# A well-formed row starts and ends with |, producing empty edge cells; drop them.
|
|
if cells and cells[0] == "":
|
|
cells = cells[1:]
|
|
if cells and cells[-1] == "":
|
|
cells = cells[:-1]
|
|
return cells
|
|
|
|
|
|
def _strip_action_cell(cell: str) -> str:
|
|
"""Normalize an action cell to the bare action name (handles `Backticked` form)."""
|
|
return cell.strip().strip("`").strip()
|
|
|
|
|
|
def parse_marker_block(md: str) -> tuple[str, dict[str, str], str]:
|
|
"""Split a doc around the marker block.
|
|
|
|
Returns (prefix_through_start_marker_line, {action: description}, suffix_from_end_marker_line).
|
|
Raises ValueError if markers are missing, unbalanced, or the enclosed table is malformed.
|
|
"""
|
|
if md.count(MARKER_START) != 1 or md.count(MARKER_END) != 1:
|
|
raise ValueError(
|
|
f"document must contain exactly one '{MARKER_START}' and one '{MARKER_END}'"
|
|
)
|
|
start_idx = md.index(MARKER_START)
|
|
end_idx = md.index(MARKER_END)
|
|
if end_idx < start_idx:
|
|
raise ValueError("end marker appears before start marker")
|
|
|
|
try:
|
|
after_start = md.index("\n", start_idx) + 1
|
|
before_end = md.rindex("\n", 0, end_idx) + 1
|
|
except ValueError:
|
|
raise ValueError(
|
|
f"'{MARKER_START}' and '{MARKER_END}' must each sit on their own line"
|
|
) from None
|
|
|
|
prefix = md[:after_start]
|
|
suffix = md[before_end:]
|
|
body = md[after_start:before_end]
|
|
return prefix, _parse_table(body), suffix
|
|
|
|
|
|
def _parse_table(body: str) -> dict[str, str]:
|
|
"""Parse {action: description} from the table inside the marker block.
|
|
|
|
Tolerant of a missing/extra Description column, padded alignment rows, and an
|
|
empty block (first migration). Rejects rows that don't look like a table row
|
|
once a table has started, loudly, rather than silently dropping content.
|
|
"""
|
|
descriptions: dict[str, str] = {}
|
|
seen_header = False
|
|
for line in body.splitlines():
|
|
stripped = line.strip()
|
|
if not stripped:
|
|
continue
|
|
if not stripped.startswith("|"):
|
|
raise ValueError(f"unexpected non-table line inside marker block: {stripped!r}")
|
|
if _SEPARATOR_ROW_RE.match(stripped):
|
|
continue
|
|
cells = _split_row(stripped)
|
|
if not cells:
|
|
continue
|
|
if len(cells) > 2:
|
|
raise ValueError(
|
|
f"malformed table row, expected at most 2 columns "
|
|
f"(escape literal pipes as '\\|'): {stripped!r}"
|
|
)
|
|
action = _strip_action_cell(cells[0])
|
|
# Skip the header row (its first cell is the literal "Action").
|
|
if not seen_header and action.lower() == "action":
|
|
seen_header = True
|
|
continue
|
|
seen_header = True
|
|
if not action:
|
|
continue
|
|
desc = cells[1].strip() if len(cells) > 1 else ""
|
|
if desc == PLACEHOLDER:
|
|
desc = ""
|
|
descriptions[action] = desc
|
|
return descriptions
|
|
|
|
|
|
def render_marker_block(actions: list[str], descriptions: dict[str, str]) -> str:
|
|
"""Render the table that goes between the marker lines (two columns).
|
|
|
|
Preserves a known description; emits the "-" placeholder for an unknown action.
|
|
"""
|
|
lines = ["| Action | Description |", "| --- | --- |"]
|
|
for action in actions:
|
|
desc = descriptions.get(action) or PLACEHOLDER
|
|
# Descriptions are held unescaped in memory; re-escape literal pipes so the
|
|
# cell round-trips through _split_row on the next regen instead of splitting.
|
|
desc = desc.replace("|", r"\|")
|
|
lines.append(f"| `{action}` | {desc} |")
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def regenerate_doc_content(
|
|
actions: list[str], doc_content: str
|
|
) -> tuple[str, list[str]]:
|
|
"""Pure: given the new action list and the current doc, return (new_doc, orphans).
|
|
|
|
Orphans are actions present in the doc's old block but no longer in source; their
|
|
descriptions are dropped from the new block (the row goes away).
|
|
"""
|
|
prefix, prior, suffix = parse_marker_block(doc_content)
|
|
keep = set(actions)
|
|
orphans = [a for a in prior if a not in keep]
|
|
body = render_marker_block(actions, prior)
|
|
return prefix + body + suffix, orphans
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Registry + driver
|
|
# --------------------------------------------------------------------------- #
|
|
def _repo_root() -> Path:
|
|
return Path(__file__).resolve().parent.parent.parent
|
|
|
|
|
|
def _load_registry(repo_root: Path) -> tuple[list[ServiceEntry], set[Path]]:
|
|
"""Load services.yaml. Returns (service entries, acknowledged deferred handler paths).
|
|
|
|
Schema: a top-level mapping with `services:` (the registered entries) and
|
|
`deferred_handlers:` (switch handlers known to exist but intentionally not yet
|
|
covered, so the unregistered-handler sentinel doesn't flag them every run).
|
|
"""
|
|
registry_path = repo_root / "tools" / "docs" / "services.yaml"
|
|
raw = yaml.safe_load(registry_path.read_text(encoding="utf-8")) or {}
|
|
entries: list[ServiceEntry] = []
|
|
for item in raw.get("services") or []:
|
|
sources = [
|
|
Source(path=repo_root / s["path"], mode=s["mode"])
|
|
for s in item.get("sources") or []
|
|
]
|
|
handler = item.get("handler")
|
|
entries.append(
|
|
ServiceEntry(
|
|
service=item["service"],
|
|
doc=repo_root / item["doc"],
|
|
sources=sources,
|
|
rename_actions=dict(item.get("rename_actions") or {}),
|
|
exclude_actions=frozenset(item.get("exclude_actions") or []),
|
|
handler=repo_root / handler if handler else None,
|
|
)
|
|
)
|
|
deferred = {
|
|
(repo_root / d["path"]).resolve()
|
|
for d in raw.get("deferred_handlers") or []
|
|
}
|
|
return entries, deferred
|
|
|
|
|
|
def _process_entry(entry: ServiceEntry) -> tuple[bool, list[str]]:
|
|
"""Regenerate one service's doc. Returns (changed, orphans). Skip-mode is a no-op."""
|
|
if not entry.sources:
|
|
return False, []
|
|
actions = extract_actions(entry)
|
|
doc_content = entry.doc.read_text(encoding="utf-8")
|
|
new_content, orphans = regenerate_doc_content(actions, doc_content)
|
|
if new_content != doc_content:
|
|
entry.doc.write_text(new_content, encoding="utf-8")
|
|
return True, orphans
|
|
return False, orphans
|
|
|
|
|
|
def _find_unregistered_handlers(
|
|
repo_root: Path, entries: list[ServiceEntry], deferred: set[Path]
|
|
) -> list[str]:
|
|
"""Top-level *Handler.java files that produce switch actions but aren't registered
|
|
and aren't on the deferred allowlist.
|
|
|
|
New-service drift sentinel: a brand-new switch handler must be either registered
|
|
or added to deferred_handlers (with a reason), or this fails --strict. REST
|
|
controllers are opt-in (per-judgment) and not flagged here.
|
|
"""
|
|
services_root = repo_root / "src/main/java/io/github/hectorvent/floci/services"
|
|
registered = {s.path.resolve() for entry in entries for s in entry.sources}
|
|
registered |= {entry.handler.resolve() for entry in entries if entry.handler}
|
|
registered |= deferred
|
|
unregistered: list[str] = []
|
|
# Recursive: handlers live at varying depths (e.g. cloudwatch/logs/, cloudwatch/metrics/).
|
|
for path in sorted(services_root.glob("**/[A-Z]*Handler.java")):
|
|
if path.resolve() in registered:
|
|
continue
|
|
if not extract_switch_actions(path.read_text(encoding="utf-8")):
|
|
continue
|
|
unregistered.append(str(path.relative_to(repo_root)))
|
|
return unregistered
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument(
|
|
"--strict",
|
|
action="store_true",
|
|
help="treat warnings (orphan actions, unregistered handlers) as errors",
|
|
)
|
|
args = parser.parse_args(argv)
|
|
|
|
repo_root = _repo_root()
|
|
entries, deferred = _load_registry(repo_root)
|
|
|
|
warnings: list[str] = []
|
|
for entry in entries:
|
|
try:
|
|
changed, orphans = _process_entry(entry)
|
|
except (ValueError, FileNotFoundError) as exc:
|
|
print(f"error: {entry.service}: {exc}", file=sys.stderr)
|
|
return 1
|
|
if changed:
|
|
print(f"updated {entry.doc.relative_to(repo_root)}")
|
|
for orphan in orphans:
|
|
warnings.append(
|
|
f"{entry.service}: action '{orphan}' was in the doc but not in source (row dropped)"
|
|
)
|
|
|
|
for handler in _find_unregistered_handlers(repo_root, entries, deferred):
|
|
warnings.append(
|
|
f"unregistered handler '{handler}' produces actions but is not in "
|
|
f"tools/docs/services.yaml (register it or add to deferred_handlers)"
|
|
)
|
|
|
|
for w in warnings:
|
|
print(f"warning: {w}", file=sys.stderr)
|
|
|
|
if args.strict and warnings:
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|