Skip to content

Commit dd66a04

Browse files
committed
Add test for controller documentation coverage
Introduces a pytest test to ensure all controller classes in shiny/playwright/controller are documented in docs/_quartodoc-testing.yml. Also updates the documentation config to include PageNavbar which was missing and the tests caught that.
1 parent 3055517 commit dd66a04

File tree

2 files changed

+148
-0
lines changed

2 files changed

+148
-0
lines changed

docs/_quartodoc-testing.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ quartodoc:
5959
- playwright.controller.NavsetTab
6060
- playwright.controller.NavsetUnderline
6161
- playwright.controller.NavPanel
62+
- playwright.controller.PageNavbar
6263
- title: Upload and download
6364
desc: Methods for interacting with Shiny app uploading and downloading controller.
6465
contents:
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import ast
2+
from pathlib import Path
3+
from typing import Set
4+
5+
import pytest
6+
import yaml
7+
8+
9+
class ControllerDocumentationChecker:
10+
"""Validates that all controller classes are documented."""
11+
12+
CONTROLLER_DIR = Path("shiny/playwright/controller")
13+
DOCS_CONFIG = Path("docs/_quartodoc-testing.yml")
14+
15+
SKIP_PATTERNS = {"Base", "Container", "Label", "StyleM"}
16+
CONTROLLER_BASE_PATTERNS = {"Base", "Container", "Label", "StyleM", "WidthLocM"}
17+
18+
def get_controller_classes(self) -> Set[str]:
19+
"""Extract public controller class names from the controller directory."""
20+
controller_classes: Set[str] = set()
21+
22+
for py_file in self.CONTROLLER_DIR.glob("*.py"):
23+
if py_file.name == "__init__.py":
24+
continue
25+
26+
try:
27+
controller_classes.update(self._extract_classes_from_file(py_file))
28+
except Exception as e:
29+
pytest.fail(f"Failed to parse {py_file}: {e}")
30+
31+
return controller_classes
32+
33+
def _extract_classes_from_file(self, py_file: Path) -> Set[str]:
34+
"""Extract controller classes from a Python file."""
35+
with open(py_file, encoding="utf-8") as f:
36+
tree = ast.parse(f.read())
37+
38+
classes: Set[str] = set()
39+
for node in ast.walk(tree):
40+
if isinstance(node, ast.ClassDef):
41+
if self._is_controller_class(node):
42+
classes.add(node.name)
43+
44+
return classes
45+
46+
def _is_controller_class(self, node: ast.ClassDef) -> bool:
47+
"""Check if a class definition represents a controller."""
48+
class_name = node.name
49+
50+
if class_name.startswith("_"):
51+
return False
52+
53+
if any(pattern in class_name for pattern in self.SKIP_PATTERNS):
54+
return False
55+
56+
if self._is_protocol_class(node):
57+
return False
58+
59+
return self._has_controller_base(node)
60+
61+
def _is_protocol_class(self, node: ast.ClassDef) -> bool:
62+
"""Check if class inherits from a Protocol."""
63+
return any(
64+
isinstance(base, ast.Name) and base.id.endswith("P") for base in node.bases
65+
)
66+
67+
def _has_controller_base(self, node: ast.ClassDef) -> bool:
68+
"""Check if class has controller base classes."""
69+
for base in node.bases:
70+
base_str = ast.unparse(base)
71+
72+
if base_str.startswith("_"):
73+
return True
74+
75+
if any(pattern in base_str for pattern in self.CONTROLLER_BASE_PATTERNS):
76+
return True
77+
78+
return False
79+
80+
def get_documented_controllers(self) -> Set[str]:
81+
"""Extract controller class names from documentation config."""
82+
try:
83+
with open(self.DOCS_CONFIG, encoding="utf-8") as f:
84+
config = yaml.safe_load(f)
85+
except FileNotFoundError:
86+
pytest.fail(f"Documentation config not found: {self.DOCS_CONFIG}")
87+
except Exception as e:
88+
pytest.fail(f"Failed to parse {self.DOCS_CONFIG}: {e}")
89+
90+
documented_controllers: Set[str] = set()
91+
92+
for section in config.get("quartodoc", {}).get("sections", []):
93+
for content in section.get("contents", []):
94+
if isinstance(content, str) and content.startswith(
95+
"playwright.controller."
96+
):
97+
class_name = content.split(".")[-1]
98+
documented_controllers.add(class_name)
99+
100+
return documented_controllers
101+
102+
103+
@pytest.fixture
104+
def checker():
105+
"""Provide a ControllerDocumentationChecker instance."""
106+
return ControllerDocumentationChecker()
107+
108+
109+
def test_all_controllers_are_documented(checker: ControllerDocumentationChecker):
110+
"""Verify all controller classes have documentation entries."""
111+
controller_classes = checker.get_controller_classes()
112+
documented_controllers = checker.get_documented_controllers()
113+
114+
missing_controllers = controller_classes - documented_controllers
115+
extra_documented = documented_controllers - controller_classes
116+
117+
error_messages: list[str] = []
118+
119+
if missing_controllers:
120+
missing_list = "\n".join(
121+
f" - playwright.controller.{cls}" for cls in sorted(missing_controllers)
122+
)
123+
error_messages.append(
124+
f"Controller classes missing from {checker.DOCS_CONFIG}:\n{missing_list}"
125+
)
126+
127+
if extra_documented:
128+
extra_list = "\n".join(
129+
f" - playwright.controller.{cls}" for cls in sorted(extra_documented)
130+
)
131+
error_messages.append(f"Classes documented but not found:\n{extra_list}")
132+
133+
if error_messages:
134+
pytest.fail("\n\n".join(error_messages))
135+
136+
assert controller_classes, "No controller classes found"
137+
assert documented_controllers, "No documented controllers found"
138+
139+
140+
def test_documented_classes_format(checker: ControllerDocumentationChecker):
141+
"""Verify documented controller entries are valid Python identifiers."""
142+
documented_controllers = checker.get_documented_controllers()
143+
144+
invalid_names = [name for name in documented_controllers if not name.isidentifier()]
145+
146+
if invalid_names:
147+
pytest.fail(f"Invalid controller class names: {invalid_names}")

0 commit comments

Comments
 (0)