|
| 1 | +import logging |
| 2 | +from typing import Any, Optional |
| 3 | + |
| 4 | +from ansible_base.lib.utils.api_path_utils import parse_path_segments |
| 5 | + |
| 6 | +logger = logging.getLogger('ansible_base.api_documentation.preprocessing_hooks') |
| 7 | + |
| 8 | +# Spec generation is single-threaded, so the following globals should be safe |
| 9 | + |
| 10 | +# Global storage for skip_ai_description operation ID prefixes |
| 11 | +# Maps operation_id prefix (e.g. "teams") -> True for views with skip_ai_description |
| 12 | +# This is shared with postprocessing_hooks.py |
| 13 | +SKIP_AI_DESCRIPTION_PREFIXES = set() |
| 14 | + |
| 15 | +# Global storage for resource_purpose values |
| 16 | +# Maps ViewSet class name -> resource_purpose string |
| 17 | +# This is shared with postprocessing_hooks.py |
| 18 | +RESOURCE_PURPOSE_MAP = {} |
| 19 | + |
| 20 | +# Global storage for ViewSet class names by operation_id prefix |
| 21 | +# Maps operation_id prefix (e.g. "authenticators") -> (ViewSet class name, path_parts_count, path_parts) |
| 22 | +# This is shared with postprocessing_hooks.py |
| 23 | +# We store the count to resolve collisions: fewer path parts = main resource, gets simple prefix |
| 24 | +# We store path_parts to generate correct compound prefixes when resolving collisions |
| 25 | +OPERATION_CLASS_MAP = {} |
| 26 | + |
| 27 | + |
| 28 | +def _get_view_class(view: Any) -> type: |
| 29 | + """Extract the ViewSet class from a view, handling DRF's view wrapping.""" |
| 30 | + return getattr(view, 'cls', view.__class__) |
| 31 | + |
| 32 | + |
| 33 | +def _extract_prefix_from_path(path: str) -> tuple[Optional[str], Optional[list[str]], Optional[int]]: |
| 34 | + """Extract the operation_id prefix from a URL path.""" |
| 35 | + path_parts = parse_path_segments(path) |
| 36 | + if not path_parts: |
| 37 | + return None, None, None |
| 38 | + |
| 39 | + prefix = path_parts[-1] |
| 40 | + path_parts_count = len(path_parts) |
| 41 | + return prefix, path_parts, path_parts_count |
| 42 | + |
| 43 | + |
| 44 | +def _create_compound_prefix(path_parts: list[str], fallback_prefix: str) -> str: |
| 45 | + """Create compound prefix from path parts (e.g., 'orgs_teams') to resolve collisions.""" |
| 46 | + if len(path_parts) >= 2: |
| 47 | + return '_'.join(path_parts[-2:]) |
| 48 | + return fallback_prefix |
| 49 | + |
| 50 | + |
| 51 | +def _handle_prefix_collision( |
| 52 | + prefix: str, class_name: str, path_parts_count: int, path_parts: list[str], operation_class_map: dict[str, tuple[str, int, list[str]]] |
| 53 | +) -> str: |
| 54 | + """ |
| 55 | + Handle collision when multiple ViewSets use the same operation_id prefix. |
| 56 | + ViewSet with fewer path parts gets simple prefix; the other gets compound prefix. |
| 57 | + """ |
| 58 | + existing_class, existing_count, existing_path_parts = operation_class_map[prefix] |
| 59 | + |
| 60 | + # Same ViewSet class - no collision |
| 61 | + if existing_class == class_name: |
| 62 | + return prefix |
| 63 | + |
| 64 | + # Different ViewSet - resolve collision |
| 65 | + if path_parts_count < existing_count: |
| 66 | + # Current is main resource - move existing to compound prefix |
| 67 | + # Use the existing entry's path_parts to create the correct compound prefix |
| 68 | + compound_prefix = _create_compound_prefix(existing_path_parts, prefix) |
| 69 | + operation_class_map[compound_prefix] = (existing_class, existing_count, existing_path_parts) |
| 70 | + operation_class_map[prefix] = (class_name, path_parts_count, path_parts) |
| 71 | + logger.debug(f"Resource collision: {class_name} (main, {path_parts_count} parts) owns '{prefix}', {existing_class} moved to '{compound_prefix}'") |
| 72 | + return prefix |
| 73 | + else: |
| 74 | + # Existing is main resource - current gets compound prefix |
| 75 | + compound_prefix = _create_compound_prefix(path_parts, prefix) |
| 76 | + operation_class_map[compound_prefix] = (class_name, path_parts_count, path_parts) |
| 77 | + logger.debug(f"Resource collision: {existing_class} (main, {existing_count} parts) keeps '{prefix}', {class_name} stored at '{compound_prefix}'") |
| 78 | + return compound_prefix |
| 79 | + |
| 80 | + |
| 81 | +def _register_skip_ai_description(view_class: type, class_name: str, prefix: str) -> None: |
| 82 | + """Register a ViewSet that should skip AI description generation.""" |
| 83 | + if getattr(view_class, 'skip_ai_description', False): |
| 84 | + SKIP_AI_DESCRIPTION_PREFIXES.add(prefix) |
| 85 | + logger.info(f"View class {class_name} (prefix: {prefix}) has skip_ai_description=True") |
| 86 | + |
| 87 | + |
| 88 | +def _register_resource_purpose(view_class: type, class_name: str, prefix: str) -> None: |
| 89 | + """Register a ViewSet's resource_purpose for description generation.""" |
| 90 | + resource_purpose = getattr(view_class, 'resource_purpose', None) |
| 91 | + if resource_purpose: |
| 92 | + RESOURCE_PURPOSE_MAP[class_name] = resource_purpose |
| 93 | + logger.debug(f"View class {class_name} (prefix: {prefix}) has resource_purpose: {resource_purpose[:50]}{'...' if len(resource_purpose) > 50 else ''}") |
| 94 | + |
| 95 | + |
| 96 | +def collect_ai_description_metadata(endpoints: Optional[list[tuple[str, str, str, Any]]], **kwargs) -> Optional[list[tuple[str, str, str, Any]]]: |
| 97 | + """ |
| 98 | + Preprocessing hook for drf-spectacular that collects metadata from ViewSets for AI description generation. |
| 99 | +
|
| 100 | + This hook runs before OpenAPI schema generation and inspects all registered ViewSets/APIViews |
| 101 | + to collect metadata that will be used by the postprocessing hook to generate x-ai-description fields. |
| 102 | +
|
| 103 | + The hook collects three types of metadata: |
| 104 | + 1. skip_ai_description flags - ViewSets that have opted out of AI description generation |
| 105 | + 2. resource_purpose values - Custom purpose strings for template-based description generation |
| 106 | + 3. Operation ID mappings - Relationships between operation prefixes and ViewSet classes |
| 107 | +
|
| 108 | + When multiple ViewSets share the same operation prefix (e.g., nested resources), the hook |
| 109 | + automatically resolves naming collisions by giving the ViewSet with fewer path parts the |
| 110 | + simple prefix, and assigning compound prefixes to nested resources. |
| 111 | +
|
| 112 | + The collected metadata is stored in global variables (SKIP_AI_DESCRIPTION_PREFIXES, |
| 113 | + RESOURCE_PURPOSE_MAP, OPERATION_CLASS_MAP) that are shared with the postprocessing hook. |
| 114 | +
|
| 115 | + Args: |
| 116 | + endpoints: List of endpoint tuples (path, path_regex, method, view) |
| 117 | + **kwargs: Additional keyword arguments (unused) |
| 118 | +
|
| 119 | + Returns: |
| 120 | + The unmodified endpoints list |
| 121 | +
|
| 122 | + Side effects: |
| 123 | + - Clears and repopulates SKIP_AI_DESCRIPTION_PREFIXES set |
| 124 | + - Clears and repopulates RESOURCE_PURPOSE_MAP dict |
| 125 | + - Clears and repopulates OPERATION_CLASS_MAP dict |
| 126 | + """ |
| 127 | + global SKIP_AI_DESCRIPTION_PREFIXES, RESOURCE_PURPOSE_MAP, OPERATION_CLASS_MAP |
| 128 | + SKIP_AI_DESCRIPTION_PREFIXES.clear() |
| 129 | + RESOURCE_PURPOSE_MAP.clear() |
| 130 | + OPERATION_CLASS_MAP.clear() |
| 131 | + |
| 132 | + if endpoints: |
| 133 | + for path, path_regex, method, view in endpoints: |
| 134 | + try: |
| 135 | + # Extract ViewSet class from the view |
| 136 | + view_class = _get_view_class(view) |
| 137 | + class_name = view_class.__name__ |
| 138 | + |
| 139 | + # Extract operation_id prefix from path |
| 140 | + prefix, path_parts, path_parts_count = _extract_prefix_from_path(path) |
| 141 | + if prefix is None: |
| 142 | + continue |
| 143 | + |
| 144 | + # Store or handle collision for this prefix |
| 145 | + if prefix not in OPERATION_CLASS_MAP: |
| 146 | + # First time seeing this prefix - store it |
| 147 | + OPERATION_CLASS_MAP[prefix] = (class_name, path_parts_count, path_parts) |
| 148 | + else: |
| 149 | + # Handle collision (different ViewSet with same prefix) |
| 150 | + prefix = _handle_prefix_collision(prefix, class_name, path_parts_count, path_parts, OPERATION_CLASS_MAP) |
| 151 | + |
| 152 | + # Register ViewSet attributes for AI description generation |
| 153 | + _register_skip_ai_description(view_class, class_name, prefix) |
| 154 | + _register_resource_purpose(view_class, class_name, prefix) |
| 155 | + |
| 156 | + except Exception as e: |
| 157 | + logger.debug(f"Error checking view metadata for {path} {method}: {e}") |
| 158 | + continue |
| 159 | + |
| 160 | + return endpoints |
0 commit comments