Skip to content

Commit 8ee7538

Browse files
authored
docs: add command to generate docs (#119)
* adds a management command for generating matching documentation * output to the correct file location (outside of the docker container) * fix docs generation to not include django output * adds tests * fixes tests * include builtin matchers * adds order of precedence to matcher tables * linting
1 parent 3c3cb11 commit 8ee7538

File tree

7 files changed

+1687
-0
lines changed

7 files changed

+1687
-0
lines changed

Makefile

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,11 @@ docker-compose-netbox-plugin-test:
2121
docker-compose-netbox-plugin-test-cover:
2222
-@$(DOCKER_COMPOSE) -f docker/docker-compose.yaml -f docker/docker-compose.test.yaml run --rm -u root -e COVERAGE_FILE=/opt/netbox/netbox/coverage/.coverage netbox sh -c "coverage run --source=netbox_diode_plugin --omit=*/migrations/* ./manage.py test --keepdb netbox_diode_plugin && coverage xml -o /opt/netbox/netbox/coverage/report.xml && coverage report -m | tee /opt/netbox/netbox/coverage/report.txt"
2323
@$(MAKE) docker-compose-netbox-plugin-down
24+
25+
.PHONY: docker-compose-generate-matching-docs
26+
docker-compose-generate-matching-docs:
27+
@$(DOCKER_COMPOSE) -f docker/docker-compose.yaml -f docker/docker-compose.test.yaml run --rm netbox python manage.py generate_matching_docs | awk '/Generating markdown documentation.../{p=1;next} p' > ./docs/matching-criteria-documentation.md
28+
29+
.PHONY: docker-compose-migrate
30+
docker-compose-migrate:
31+
@$(DOCKER_COMPOSE) -f docker/docker-compose.yaml -f docker/docker-compose.test.yaml run --rm netbox python manage.py migrate

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,12 @@ cd /opt/netbox/netbox
9999
make docker-compose-netbox-plugin-test
100100
```
101101

102+
## Generating Documentation
103+
Generates documentation on how diode entities are matched. The generated documentation is output to [here](./docs/matching-criteria-documentation.md).
104+
```shell
105+
make docker-compose-generate-matching-docs
106+
```
107+
102108
## License
103109

104110
Distributed under the NetBox Limited Use License 1.0. See [LICENSE.md](./LICENSE.md) for more information.

docs/matching-criteria-documentation.md

Lines changed: 773 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Django management package for netbox_diode_plugin."""
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Django management commands for netbox_diode_plugin."""
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
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

Comments
 (0)