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