|
| 1 | +#!/usr/bin/env python |
| 2 | +"""Django management command to generate markdown documentation for NetBox Diode Plugin matching criteria.""" |
| 3 | + |
| 4 | +from dataclasses import dataclass |
| 5 | +from typing import Optional |
| 6 | + |
| 7 | +from django.core.management.base import BaseCommand |
| 8 | + |
| 9 | +from netbox_diode_plugin.api.differ import SUPPORTED_MODELS |
| 10 | +from netbox_diode_plugin.api.matcher import _LOGICAL_MATCHERS, get_model_matchers |
| 11 | + |
| 12 | + |
| 13 | +@dataclass |
| 14 | +class MatcherInfo: |
| 15 | + """Information about a matcher for documentation.""" |
| 16 | + |
| 17 | + name: str |
| 18 | + fields: list[str] | None = None |
| 19 | + condition: str | None = None |
| 20 | + description: str | None = None |
| 21 | + matcher_type: str = "ObjectMatchCriteria" |
| 22 | + version_constraints: str | None = None |
| 23 | + matcher_source: str = "logical" # "logical" or "builtin" |
| 24 | + |
| 25 | + |
| 26 | +class Command(BaseCommand): |
| 27 | + """Django management command to generate markdown documentation for NetBox Diode Plugin matching criteria.""" |
| 28 | + |
| 29 | + help = "Generate markdown documentation for NetBox Diode Plugin matching criteria" |
| 30 | + |
| 31 | + def extract_condition_description(self, condition) -> str: |
| 32 | + """Extract a human-readable description of a Q condition.""" |
| 33 | + if condition is None: |
| 34 | + return "None" |
| 35 | + |
| 36 | + # Handle simple conditions |
| 37 | + if hasattr(condition, 'children'): |
| 38 | + conditions = [] |
| 39 | + for child in condition.children: |
| 40 | + if isinstance(child, tuple): |
| 41 | + field, value = child |
| 42 | + if field.endswith('__isnull'): |
| 43 | + field_name = field[:-8] |
| 44 | + if value: |
| 45 | + conditions.append(f"{field_name} is NULL") |
| 46 | + else: |
| 47 | + conditions.append(f"{field_name} is NOT NULL") |
| 48 | + else: |
| 49 | + conditions.append(f"{field} = {value}") |
| 50 | + else: |
| 51 | + conditions.append(str(child)) |
| 52 | + |
| 53 | + connector = " AND " if condition.connector == "AND" else " OR " |
| 54 | + return connector.join(conditions) |
| 55 | + |
| 56 | + return str(condition) |
| 57 | + |
| 58 | + def get_matcher_description(self, matcher) -> str: # noqa: C901 |
| 59 | + """Generate a human-readable description of what the matcher does.""" |
| 60 | + # Handle IP Network matchers |
| 61 | + if hasattr(matcher, 'ip_fields') and matcher.ip_fields and hasattr(matcher, 'vrf_field') and matcher.vrf_field: |
| 62 | + ip_fields_str = ", ".join(matcher.ip_fields) |
| 63 | + if matcher.name.startswith('logical_ip_address_global_no_vrf'): |
| 64 | + return f"Matches IP address {ip_fields_str} in global namespace (no VRF)" |
| 65 | + if matcher.name.startswith('logical_ip_address_within_vrf'): |
| 66 | + return f"Matches IP address {ip_fields_str} within VRF" |
| 67 | + if matcher.name.startswith('logical_ip_range'): |
| 68 | + return f"Matches IP range {ip_fields_str} within VRF context" |
| 69 | + |
| 70 | + # Handle CustomFieldMatcher |
| 71 | + if hasattr(matcher, 'custom_field') and matcher.custom_field: |
| 72 | + return f"Matches on unique custom field: {matcher.custom_field}" |
| 73 | + |
| 74 | + # Handle AutoSlugMatcher |
| 75 | + if hasattr(matcher, 'slug_field') and matcher.slug_field: |
| 76 | + return f"Matches on auto-generated slug field: {matcher.slug_field}" |
| 77 | + |
| 78 | + # Handle builtin unique field matchers |
| 79 | + if matcher.name.startswith('unique_') and hasattr(matcher, 'fields') and matcher.fields: |
| 80 | + field_name = matcher.fields[0] if len(matcher.fields) == 1 else ", ".join(matcher.fields) |
| 81 | + if matcher.name.startswith('unique_'): |
| 82 | + return f"Matches on unique field(s): {field_name}" |
| 83 | + |
| 84 | + # Handle builtin UniqueConstraint matchers |
| 85 | + if hasattr(matcher, 'fields') and matcher.fields and not matcher.name.startswith('logical_'): |
| 86 | + fields_str = ", ".join(matcher.fields) |
| 87 | + if hasattr(matcher, 'condition') and matcher.condition: |
| 88 | + condition_desc = self.extract_condition_description(matcher.condition) |
| 89 | + return f"Matches on unique constraint fields: {fields_str} where {condition_desc}" |
| 90 | + return f"Matches on unique constraint fields: {fields_str}" |
| 91 | + |
| 92 | + # Standard field-based matcher |
| 93 | + if hasattr(matcher, 'fields') and matcher.fields: |
| 94 | + fields_str = ", ".join(matcher.fields) |
| 95 | + if hasattr(matcher, 'condition') and matcher.condition: |
| 96 | + condition_desc = self.extract_condition_description(matcher.condition) |
| 97 | + return f"Matches on fields: {fields_str} where {condition_desc}" |
| 98 | + return f"Matches on fields: {fields_str}" |
| 99 | + |
| 100 | + return "Custom matcher" |
| 101 | + |
| 102 | + def get_version_constraints(self, matcher) -> str | None: |
| 103 | + """Get version constraints as a string.""" |
| 104 | + constraints = [] |
| 105 | + if hasattr(matcher, 'min_version') and matcher.min_version: |
| 106 | + constraints.append(f"≥{matcher.min_version}") |
| 107 | + if hasattr(matcher, 'max_version') and matcher.max_version: |
| 108 | + constraints.append(f"≤{matcher.max_version}") |
| 109 | + |
| 110 | + return " ".join(constraints) if constraints else None |
| 111 | + |
| 112 | + def analyze_logical_matchers(self) -> dict[str, list[MatcherInfo]]: |
| 113 | + """Analyze the logical matchers and extract documentation information.""" |
| 114 | + documentation = {} |
| 115 | + |
| 116 | + for object_type, matcher_factory in _LOGICAL_MATCHERS.items(): |
| 117 | + matchers = matcher_factory() |
| 118 | + matcher_infos = [] |
| 119 | + |
| 120 | + for matcher in matchers: |
| 121 | + info = MatcherInfo( |
| 122 | + name=matcher.name, |
| 123 | + fields=list(matcher.fields) if hasattr(matcher, 'fields') and matcher.fields else None, |
| 124 | + condition=self.extract_condition_description(matcher.condition) if hasattr(matcher, 'condition') else None, |
| 125 | + description=self.get_matcher_description(matcher), |
| 126 | + matcher_type=matcher.__class__.__name__, |
| 127 | + version_constraints=self.get_version_constraints(matcher), |
| 128 | + matcher_source="logical" |
| 129 | + ) |
| 130 | + matcher_infos.append(info) |
| 131 | + |
| 132 | + documentation[object_type] = matcher_infos |
| 133 | + |
| 134 | + return documentation |
| 135 | + |
| 136 | + def analyze_builtin_matchers(self) -> dict[str, list[MatcherInfo]]: |
| 137 | + """Analyze the builtin matchers and extract documentation information.""" |
| 138 | + documentation = {} |
| 139 | + |
| 140 | + for object_type, model_info in SUPPORTED_MODELS.items(): |
| 141 | + model_class = model_info["model"] |
| 142 | + matchers = get_model_matchers(model_class) |
| 143 | + matcher_infos = [] |
| 144 | + |
| 145 | + for matcher in matchers: |
| 146 | + # Skip logical matchers as they're already handled |
| 147 | + if matcher.name.startswith('logical_'): |
| 148 | + continue |
| 149 | + |
| 150 | + # Extract fields for builtin matchers |
| 151 | + fields = None |
| 152 | + if hasattr(matcher, 'fields') and matcher.fields: |
| 153 | + fields = list(matcher.fields) |
| 154 | + elif hasattr(matcher, 'custom_field'): |
| 155 | + fields = [f"custom_fields.{matcher.custom_field}"] |
| 156 | + elif hasattr(matcher, 'slug_field'): |
| 157 | + fields = [matcher.slug_field] |
| 158 | + |
| 159 | + info = MatcherInfo( |
| 160 | + name=matcher.name, |
| 161 | + fields=fields, |
| 162 | + condition=self.extract_condition_description(matcher.condition) if hasattr(matcher, 'condition') else None, |
| 163 | + description=self.get_matcher_description(matcher), |
| 164 | + matcher_type=matcher.__class__.__name__, |
| 165 | + version_constraints=self.get_version_constraints(matcher), |
| 166 | + matcher_source="builtin" |
| 167 | + ) |
| 168 | + matcher_infos.append(info) |
| 169 | + |
| 170 | + if matcher_infos: # Only add if there are builtin matchers |
| 171 | + documentation[object_type] = matcher_infos |
| 172 | + |
| 173 | + return documentation |
| 174 | + |
| 175 | + def combine_matchers( |
| 176 | + self, |
| 177 | + logical_docs: dict[str, list[MatcherInfo]], |
| 178 | + builtin_docs: dict[str, list[MatcherInfo]], |
| 179 | + ) -> dict[str, list[MatcherInfo]]: |
| 180 | + """Combine logical and builtin matchers into a single documentation structure.""" |
| 181 | + combined = {} |
| 182 | + |
| 183 | + # Get all object types |
| 184 | + all_object_types = set(logical_docs.keys()) | set(builtin_docs.keys()) |
| 185 | + |
| 186 | + for object_type in all_object_types: |
| 187 | + matchers = [] |
| 188 | + |
| 189 | + # Add logical matchers |
| 190 | + if object_type in logical_docs: |
| 191 | + matchers.extend(logical_docs[object_type]) |
| 192 | + |
| 193 | + # Add builtin matchers |
| 194 | + if object_type in builtin_docs: |
| 195 | + matchers.extend(builtin_docs[object_type]) |
| 196 | + |
| 197 | + if matchers: |
| 198 | + combined[object_type] = matchers |
| 199 | + |
| 200 | + return combined |
| 201 | + |
| 202 | + def generate_markdown_table(self, docs: dict[str, list[MatcherInfo]]) -> str: |
| 203 | + """Generate a markdown table from the documentation.""" |
| 204 | + markdown = [] |
| 205 | + markdown.append("# NetBox Diode Plugin - Object Matching Criteria") |
| 206 | + markdown.append("") |
| 207 | + markdown.append( |
| 208 | + "This document describes how the Diode NetBox Plugin matches existing objects when applying changes. " |
| 209 | + "The matchers will be applied in the order of their precedence, unttil one of them matches." |
| 210 | + ) |
| 211 | + markdown.append("") |
| 212 | + markdown.append("## Matcher Types") |
| 213 | + markdown.append("") |
| 214 | + markdown.append("- **Logical Matchers**: Custom matching criteria that represent likely user intent") |
| 215 | + markdown.append( |
| 216 | + "- **Builtin Matchers**: Automatically generated from NetBox model constraints " |
| 217 | + "(unique fields, unique constraints, custom fields, auto-slugs)" |
| 218 | + ) |
| 219 | + markdown.append("") |
| 220 | + |
| 221 | + # Sort object types for consistent output |
| 222 | + sorted_object_types = sorted(docs.keys()) |
| 223 | + |
| 224 | + for object_type in sorted_object_types: |
| 225 | + matchers = docs[object_type] |
| 226 | + |
| 227 | + markdown.append(f"## {object_type}") |
| 228 | + markdown.append("") |
| 229 | + |
| 230 | + if not matchers: |
| 231 | + markdown.append("No specific matching criteria defined.") |
| 232 | + markdown.append("") |
| 233 | + continue |
| 234 | + |
| 235 | + # Create table header |
| 236 | + markdown.append("| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints |") |
| 237 | + markdown.append("|--------------|---------------------|------|--------|-----------|-------------|---------------------|") |
| 238 | + |
| 239 | + for precedence, matcher in enumerate(matchers, start=1): |
| 240 | + # Escape pipe characters in table cells |
| 241 | + name = matcher.name.replace("|", "\\|") if matcher.name else "N/A" |
| 242 | + matcher_type = matcher.matcher_source.replace("|", "\\|") |
| 243 | + fields_str = ", ".join(matcher.fields).replace("|", "\\|") if matcher.fields else "" |
| 244 | + condition_str = matcher.condition.replace("|", "\\|") if matcher.condition and matcher.condition != "None" else "N/A" |
| 245 | + description = matcher.description.replace("|", "\\|") if matcher.description else "N/A" |
| 246 | + version_str = matcher.version_constraints.replace("|", "\\|") if matcher.version_constraints else "All versions" |
| 247 | + |
| 248 | + markdown.append( |
| 249 | + f"| {name} | {precedence} | {matcher_type} | {fields_str} | {condition_str} | {description} | {version_str} |" |
| 250 | + ) |
| 251 | + |
| 252 | + markdown.append("") |
| 253 | + |
| 254 | + return "\n".join(markdown) |
| 255 | + |
| 256 | + def handle(self, *args, **options): |
| 257 | + """Handle the command execution.""" |
| 258 | + self.stdout.write("Analyzing logical matching criteria...") |
| 259 | + logical_docs = self.analyze_logical_matchers() |
| 260 | + |
| 261 | + self.stdout.write("Analyzing builtin matching criteria...") |
| 262 | + builtin_docs = self.analyze_builtin_matchers() |
| 263 | + |
| 264 | + self.stdout.write("Combining matchers...") |
| 265 | + combined_docs = self.combine_matchers(logical_docs, builtin_docs) |
| 266 | + |
| 267 | + self.stdout.write("Generating markdown documentation...") |
| 268 | + markdown_content = self.generate_markdown_table(combined_docs) |
| 269 | + self.stdout.write(markdown_content) |
0 commit comments