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

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.""" 

4 

5from dataclasses import dataclass 

6from pathlib import Path 

7 

8from .name_template import ( 

9 TEMPLATE_VARIABLES, 

10 NamingContext, 

11 variable_token, 

12 variables_for_context, 

13) 

14 

15 

16@dataclass(frozen=True, slots=True) 

17class TemplateVariableReferenceRow: 

18 """Describe one variable across naming contexts with equal operator guidance.""" 

19 

20 contexts: tuple[NamingContext, ...] 

21 name: str 

22 description: str 

23 example: str 

24 

25 @property 

26 def token(self): 

27 """Return the variable as it appears in a name template.""" 

28 return f"{{{self.name}}}" 

29 

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) 

34 

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()) 

43 

44 

45CONTEXT_LABELS = { 

46 NamingContext.MODULE_MEMBER: "Module member", 

47 NamingContext.MODULE_PARENT: "Module parent", 

48 NamingContext.DEVICE_INTERFACE: "Device interface", 

49} 

50 

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} 

56 

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} 

62 

63GENERATED_REGION_BEGIN = "<!-- BEGIN GENERATED TEMPLATE VARIABLE REFERENCE -->" 

64GENERATED_REGION_END = "<!-- END GENERATED TEMPLATE VARIABLE REFERENCE -->" 

65 

66 

67@dataclass(frozen=True, slots=True) 

68class GeneratedReferenceRegion: 

69 """Locate and format one generated template-variable reference.""" 

70 

71 path: Path 

72 heading_level: int 

73 title: str | None = None 

74 

75 

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) 

82 

83 

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) 

87 

88 

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}" 

97 

98 

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) 

112 

113 

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 ) 

125 

126 

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() 

149 

150 

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}" 

160 

161 

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 ) 

169 

170 

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}") 

177 

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)