Coverage for netbox_interface_name_rules / template_variable_reference.py: 100.00%
81 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-16 10:13 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-16 10:13 +0000
1# SPDX-License-Identifier: Apache-2.0
2# Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
3"""Present the template-variable catalogue to operators and agents."""
5from dataclasses import dataclass
6from pathlib import Path
8from .name_template import (
9 TEMPLATE_VARIABLES,
10 NamingContext,
11 variable_token,
12 variables_for_context,
13)
16@dataclass(frozen=True, slots=True)
17class TemplateVariableReferenceRow:
18 """Describe one variable across naming contexts with equal operator guidance."""
20 contexts: tuple[NamingContext, ...]
21 name: str
22 description: str
23 example: str
25 @property
26 def token(self):
27 """Return the variable as it appears in a name template."""
28 return f"{{{self.name}}}"
30 @property
31 def context_values(self):
32 """Return the naming contexts as a space-separated HTML data value."""
33 return " ".join(context.value for context in self.contexts)
35 @property
36 def rule_type_label(self):
37 """Name each rule kind and template field represented by the row."""
38 fields_by_rule_kind = {}
39 for context in self.contexts:
40 rule_kind, field = CONTEXT_RULE_FIELDS[context]
41 fields_by_rule_kind.setdefault(rule_kind, []).append(field)
42 return "; ".join(f"{rule_kind}: {', '.join(fields)}" for rule_kind, fields in fields_by_rule_kind.items())
45CONTEXT_LABELS = {
46 NamingContext.MODULE_MEMBER: "Module member",
47 NamingContext.MODULE_PARENT: "Module parent",
48 NamingContext.DEVICE_INTERFACE: "Device interface",
49}
51CONTEXT_SUMMARIES = {
52 NamingContext.MODULE_MEMBER: "Module rules use these variables in the Name Template field.",
53 NamingContext.MODULE_PARENT: "Module rules use these variables in the Parent Name Template field.",
54 NamingContext.DEVICE_INTERFACE: "Device-interface rules use these variables in the Name Template field.",
55}
57CONTEXT_RULE_FIELDS = {
58 NamingContext.MODULE_MEMBER: ("Module", "Name Template"),
59 NamingContext.MODULE_PARENT: ("Module", "Parent Name Template"),
60 NamingContext.DEVICE_INTERFACE: ("Device interface", "Name Template"),
61}
63GENERATED_REGION_BEGIN = "<!-- BEGIN GENERATED TEMPLATE VARIABLE REFERENCE -->"
64GENERATED_REGION_END = "<!-- END GENERATED TEMPLATE VARIABLE REFERENCE -->"
67@dataclass(frozen=True, slots=True)
68class GeneratedReferenceRegion:
69 """Locate and format one generated template-variable reference."""
71 path: Path
72 heading_level: int
73 title: str | None = None
76PROJECT_ROOT = Path(__file__).resolve().parents[1]
77GENERATED_REFERENCE_REGIONS = (
78 GeneratedReferenceRegion(PROJECT_ROOT / "docs" / "template-variables.md", 3),
79 GeneratedReferenceRegion(PROJECT_ROOT / "contrib" / "README.md", 3),
80 GeneratedReferenceRegion(PROJECT_ROOT / ".github" / "copilot-instructions.md", 4, "Template variables"),
81)
84def naming_context_reference():
85 """Return each naming context with its operator-facing summary."""
86 return tuple({"label": CONTEXT_LABELS[context], "summary": CONTEXT_SUMMARIES[context]} for context in NamingContext)
89def _description(variable, context):
90 """Return the guidance for *variable* in *context*, which says when arithmetic cannot read it."""
91 description = dict(variable.descriptions)[context]
92 if not variable.text:
93 return description
94 counterpart = variable.numeric_counterpart
95 note = "" if counterpart is None else f" Use {variable_token(counterpart)} in arithmetic."
96 return f"{description} Text: arithmetic cannot use it.{note}"
99def variable_reference_rows():
100 """Group catalogue contexts that give one variable equal operator guidance."""
101 rows = []
102 for variable in TEMPLATE_VARIABLES:
103 contexts_by_guidance = {}
104 for context, _source in variable.providers:
105 key = (_description(variable, context), variable.example)
106 contexts_by_guidance.setdefault(key, []).append(context)
107 rows.extend(
108 TemplateVariableReferenceRow(tuple(contexts), variable.name, description, example)
109 for (description, example), contexts in contexts_by_guidance.items()
110 )
111 return tuple(rows)
114def rule_tester_variable_rows(context):
115 """Return the catalogue rows and descriptions for the preview's naming context."""
116 return tuple(
117 TemplateVariableReferenceRow(
118 contexts=(context,),
119 name=variable.name,
120 description=_description(variable, context),
121 example=variable.example,
122 )
123 for variable in variables_for_context(context)
124 )
127def render_markdown_reference(heading_level, title=None):
128 """Render the catalogue as Markdown tables grouped by naming context."""
129 lines = []
130 if title:
131 lines.extend((f"{'#' * (heading_level - 1)} {title}", ""))
132 rows = variable_reference_rows()
133 for context in NamingContext:
134 lines.extend(
135 (
136 f"{'#' * heading_level} {CONTEXT_LABELS[context]} names",
137 "",
138 CONTEXT_SUMMARIES[context],
139 "",
140 "| Variable | Description | Example |",
141 "|----------|-------------|---------|",
142 )
143 )
144 lines.extend(
145 f"| `{row.token}` | {row.description} | `{row.example}` |" for row in rows if context in row.contexts
146 )
147 lines.append("")
148 return "\n".join(lines).rstrip()
151def regenerated_document(region):
152 """Return one document with its generated region refreshed."""
153 text = region.path.read_text(encoding="utf-8")
154 if text.count(GENERATED_REGION_BEGIN) != 1 or text.count(GENERATED_REGION_END) != 1:
155 raise ValueError(f"{region.path} must contain exactly one generated template-variable region")
156 before, remainder = text.split(GENERATED_REGION_BEGIN, 1)
157 _old_content, after = remainder.split(GENERATED_REGION_END, 1)
158 generated = render_markdown_reference(region.heading_level, region.title)
159 return f"{before}{GENERATED_REGION_BEGIN}\n{generated}\n{GENERATED_REGION_END}{after}"
162def outdated_generated_regions():
163 """Return generated-reference paths whose committed content is stale."""
164 return tuple(
165 str(region.path.relative_to(PROJECT_ROOT))
166 for region in GENERATED_REFERENCE_REGIONS
167 if region.path.read_text(encoding="utf-8") != regenerated_document(region)
168 )
171def write_generated_regions():
172 """Regenerate every catalogue-backed documentation region and return changed paths."""
173 missing_paths = tuple(region.path for region in GENERATED_REFERENCE_REGIONS if not region.path.exists())
174 if missing_paths:
175 paths = ", ".join(str(path) for path in missing_paths)
176 raise FileNotFoundError(f"Reference regions exist only in a source checkout. Missing paths: {paths}")
178 documents = tuple(
179 (region, region.path.read_text(encoding="utf-8"), regenerated_document(region))
180 for region in GENERATED_REFERENCE_REGIONS
181 )
182 changed = []
183 for region, current, generated in documents:
184 if current == generated:
185 continue
186 region.path.write_text(generated, encoding="utf-8")
187 changed.append(str(region.path.relative_to(PROJECT_ROOT)))
188 return tuple(changed)