#!/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 = "" MARKER_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())