Coverage for liitos/changes.py: 91.37%

149 statements  

« prev     ^ index     » next       coverage.py v7.6.8, created at 2024-11-25 15:36:16 +00:00

1"""Weave the content of the changes data file into the output structure (for now LaTeX). 

2 

3# Supported Table Layouts 

4 

5# Layout `named` (the old default) 

6 

7| Iss. | Rev. | Author | Description | 

8|:-----|:-----|:-----------------|:--------------------------------------------| 

9| 001 | 00 | Ann Author | Initial issue | 

10| 001 | 01 | Another Author | The mistakes were fixed | 

11| 002 | 00 | That was them | Other mistakes, maybe, who knows, not ours? | 

12 

13Table: The named table is simple to grow by appending rows. 

14 

15The named layout relies on the skeleton in the pubisher.tex.in template. 

16 

17# Layout `anonymous` (the new default) 

18 

19| Iss. | Rev. | Description | 

20|:-----|:-----|:---------------------------------------------------------------| 

21| 001 | 00 | Initial issue | 

22| 001 | 01 | The mistakes were fixed | 

23| 002 | 00 | Other mistakes, maybe, who knows, not ours? | 

24 

25Table: This anonymous table is also simple to grow by appending rows. 

26 

27The anonymous layout requires more dynamic LaTeX generation and thus generates the construct 

28from the data inside this module. 

29""" 

30 

31import pathlib 

32from typing import Generator, Union, no_type_check 

33 

34import liitos.gather as gat 

35import liitos.template as tpl 

36import liitos.tools as too 

37from liitos import ENCODING, ExternalsType, LOG_SEPARATOR, OptionsType, PathLike, log 

38 

39CHANGE_ROW_TOKEN = r'THE.ISSUE.CODE & THE.REVISION.CODE & THE.AUTHOR.NAME & THE.DESCRIPTION \\' # nosec B105 

40DEFAULT_REVISION = '00' 

41ROW_TEMPLATE_NAMED = r'issue & revision & author & summary \\' 

42ROW_TEMPLATE_ANONYMOUS = r'issue & revision & summary \\' 

43ROW_TEMPLATE_ANONYMOUS_VERSION = r'\centering issue & summary \\' 

44GLUE = '\n\\hline\n' 

45JSON_CHANNEL = 'json' 

46YAML_CHANNEL = 'yaml' 

47COLUMNS_EXPECTED = sorted(['author', 'date', 'issue', 'revision', 'summary']) 

48COLUMNS_MINIMAL = sorted(['issue', 'summary']) # removed zero slot 'author' for data driven anonymous layout option 

49CUT_MARKER_CHANGES_TOP = '% |-- changes - cut - marker - top -->' 

50CUT_MARKER_CHANGES_BOTTOM = '% <-- changes - cut - marker - bottom --|' 

51CUT_MARKER_NOTICES_TOP = '% |-- notices - cut - marker - top -->' 

52CUT_MARKER_NOTICES_BOTTOM = '% <-- notices - cut - marker - bottom --|' 

53TOKEN_ADJUSTED_PUSHDOWN = r'\AdustedPushdown' # nosec B105 

54DEFAULT_ADJUSTED_PUSHDOWN_VALUE = 14 

55 

56LAYOUT_NAMED_CUT_MARKER_TOP = '% |-- layout named - cut - marker - top -->' 

57LAYOUT_NAMED_CUT_MARKER_BOTTOM = '% <-- layout named - cut - marker - bottom --|' 

58LAYOUT_ANONYMIZED_INSERT_MARKER = '% |-- layout anonymized - insert - marker --|' 

59 

60NL = '\n' 

61TABLE_ANONYMOUS_PRE = r"""\ 

62\begin{longtable}[]{| 

63 >{\raggedright\arraybackslash}p{(\columnwidth - 6\tabcolsep) * \real{0.0600}}| 

64 >{\raggedright\arraybackslash}p{(\columnwidth - 6\tabcolsep) * \real{0.0600}}| 

65 >{\raggedright\arraybackslash}p{(\columnwidth - 6\tabcolsep) * \real{0.8500}}|} 

66\hline 

67\begin{minipage}[b]{\linewidth}\raggedright 

68\textbf{\theChangeLogIssLabel} 

69\end{minipage} & \begin{minipage}[b]{\linewidth}\raggedright 

70\textbf{\theChangeLogRevLabel} 

71\end{minipage} & \begin{minipage}[b]{\linewidth}\raggedright 

72\textbf{\theChangeLogDescLabel} 

73\end{minipage} \\ 

74\hline 

75""" 

76TABLE_ANONYMOUS_ROWS = r'THE.ISSUE.CODE & THE.REVISION.CODE & THE.DESCRIPTION \\' 

77TABLE_ANONYMOUS_POST = r"""\hline 

78\end{longtable} 

79""" 

80 

81TABLE_ANONYMOUS_VERSION_PRE = r"""\ 

82\begin{longtable}[]{| 

83 >{\raggedright\arraybackslash}p{(\columnwidth - 6\tabcolsep) * \real{0.0800}}| 

84 >{\raggedright\arraybackslash}p{(\columnwidth - 6\tabcolsep) * \real{0.8900}}|} 

85\hline 

86\begin{minipage}[b]{\linewidth}\raggedright 

87\textbf{\theChangeLogIssLabel} 

88\end{minipage} & \begin{minipage}[b]{\linewidth}\raggedright 

89\textbf{\hskip 12em \theChangeLogDescLabel} 

90\end{minipage} \\ 

91\hline 

92""" 

93TABLE_ANONYMOUS_VERSION_ROWS = r'THE.ISSUE.CODE & THE.REVISION.CODE & THE.DESCRIPTION \\' 

94TABLE_ANONYMOUS_VERSION_POST = r"""\hline 

95\end{longtable} 

96""" 

97 

98 

99def get_layout(layout_path: PathLike, target_key: str, facet_key: str) -> dict[str, dict[str, dict[str, bool]]]: 

100 """Boolean layout decisions on bookmatter and publisher page conten. 

101 

102 Deprecated as the known use cases evolved into a different direction ... 

103 """ 

104 layout = {'layout': {'global': {'has_approvals': True, 'has_changes': True, 'has_notices': True}}} 

105 if layout_path: 

106 log.info(f'loading layout from {layout_path=} for changes and notices') 

107 return gat.load_layout(facet_key, target_key, layout_path)[0] # type: ignore 

108 

109 log.info('using default layout for approvals') 

110 return layout 

111 

112 

113def derive_model(model_path: PathLike) -> tuple[str, list[str]]: 

114 """Derive the model as channel type and column model from the given path.""" 

115 channel = JSON_CHANNEL if str(model_path).endswith('.json') else YAML_CHANNEL 

116 columns_expected = COLUMNS_MINIMAL if channel == JSON_CHANNEL else COLUMNS_EXPECTED 

117 

118 return channel, columns_expected 

119 

120 

121def columns_are_present(columns_present: list[str], columns_expected: list[str]) -> bool: 

122 """Ensure the needed columns are present.""" 

123 return all(column in columns_expected for column in columns_present) 

124 

125 

126@no_type_check 

127def normalize(changes: object, channel: str, columns_expected: list[str]) -> list[dict[str, str]]: 

128 """Normalize the channel specific topology of the model into a logical model. 

129 

130 On error an empty logical model is returned. 

131 """ 

132 if channel == JSON_CHANNEL: 

133 for slot, change in enumerate(changes[0]['changes'], start=1): 

134 if not set(columns_expected).issubset(set(change)): 

135 log.error('unexpected column model!') 

136 log.error(f'- expected: ({columns_expected})') 

137 log.error(f'- minimal: ({COLUMNS_MINIMAL})') 

138 log.error(f'- but found: ({change}) for entry #{slot}') 

139 return [] 

140 

141 if channel == YAML_CHANNEL: 

142 for slot, change in enumerate(changes[0]['changes'], start=1): 

143 model = sorted(change.keys()) 

144 if not set(COLUMNS_MINIMAL).issubset(set(model)): 

145 log.error('unexpected column model!') 

146 log.error(f'- expected: ({columns_expected})') 

147 log.error(f'- minimal: ({COLUMNS_MINIMAL})') 

148 log.error(f'- but found: ({model}) in slot {slot}') 

149 return [] 

150 

151 model = [] 

152 if channel == JSON_CHANNEL: 

153 for change in changes[0]['changes']: 

154 issue, author, summary = change['issue'], change.get('author', None), change['summary'] 

155 revision = change.get('revision', DEFAULT_REVISION) 

156 is_version = bool(change.get('version', False)) 

157 model.append( 

158 {'issue': issue, 'revision': revision, 'author': author, 'summary': summary, 'is_version': is_version} 

159 ) 

160 return model 

161 

162 for change in changes[0]['changes']: 

163 author = change.get('author', None) 

164 issue = change['issue'] 

165 revision = change.get('revision', DEFAULT_REVISION) 

166 is_version = bool(change.get('version', False)) 

167 summary = change['summary'] 

168 model.append( 

169 {'issue': issue, 'revision': revision, 'author': author, 'summary': summary, 'is_version': is_version} 

170 ) 

171 

172 return model 

173 

174 

175def adjust_pushdown_gen(text_lines: list[str], pushdown: float) -> Generator[str, None, None]: 

176 """Update the pushdown line filtering the incoming lines.""" 

177 for line in text_lines: 

178 if TOKEN_ADJUSTED_PUSHDOWN in line: 

179 line = line.replace(TOKEN_ADJUSTED_PUSHDOWN, f'{pushdown}em') 

180 log.info(f'set adjusted pushdown value {pushdown}em') 

181 yield line 

182 

183 

184def weave( 

185 doc_root: Union[str, pathlib.Path], 

186 structure_name: str, 

187 target_key: str, 

188 facet_key: str, 

189 options: OptionsType, 

190 externals: ExternalsType, 

191) -> int: 

192 """Later alligator.""" 

193 log.info(LOG_SEPARATOR) 

194 log.info('entered changes weave function ...') 

195 structure, asset_map = gat.prelude( 

196 doc_root=doc_root, structure_name=structure_name, target_key=target_key, facet_key=facet_key, command='changes' 

197 ) 

198 

199 layout_path = asset_map[target_key][facet_key].get(gat.KEY_LAYOUT, '') 

200 layout = get_layout(layout_path, target_key=target_key, facet_key=facet_key) 

201 log.info(f'{layout=}') 

202 

203 log.info(LOG_SEPARATOR) 

204 changes_path = asset_map[target_key][facet_key][gat.KEY_CHANGES] 

205 channel, columns_expected = derive_model(changes_path) 

206 log.info(f'detected changes channel ({channel}) weaving in from ({changes_path})') 

207 

208 log.info(f'loading changes from {changes_path=}') 

209 changes = gat.load_changes(facet_key, target_key, changes_path) 

210 log.info(f'{changes=}') 

211 

212 log.info(LOG_SEPARATOR) 

213 log.info('plausibility tests for changes ...') 

214 

215 logical_model = normalize(changes, channel=channel, columns_expected=columns_expected) 

216 

217 is_anonymized = any(entry.get('author') is None for entry in logical_model) 

218 is_version_semantics = False 

219 if is_anonymized: 

220 if logical_model and logical_model[0].get('is_version', False): 220 ↛ 221line 220 didn't jump to line 221 because the condition on line 220 was never true

221 is_version_semantics = True 

222 rows = [ 

223 ROW_TEMPLATE_ANONYMOUS_VERSION.replace('issue', kv['issue']).replace('summary', kv['summary']) 

224 for kv in logical_model 

225 ] 

226 else: 

227 rows = [ 

228 ROW_TEMPLATE_ANONYMOUS.replace('issue', kv['issue']) 

229 .replace('revision', kv['revision']) 

230 .replace('summary', kv['summary']) 

231 for kv in logical_model 

232 ] 

233 else: 

234 rows = [ 

235 ROW_TEMPLATE_NAMED.replace('issue', kv['issue']) 

236 .replace('revision', kv['revision']) 

237 .replace('author', kv['author']) 

238 .replace('summary', kv['summary']) 

239 for kv in logical_model 

240 ] 

241 

242 pushdown = DEFAULT_ADJUSTED_PUSHDOWN_VALUE 

243 log.info(f'calculated adjusted pushdown to be {pushdown}em') 

244 

245 publisher_template_is_custom = bool(externals['publisher']['is_custom']) 

246 publisher_template = str(externals['publisher']['id']) 

247 publisher_path = pathlib.Path('render/pdf/publisher.tex') 

248 

249 publisher_template = tpl.load_resource(publisher_template, publisher_template_is_custom) 

250 lines = [line.rstrip() for line in publisher_template.split(NL)] 

251 

252 if any(TOKEN_ADJUSTED_PUSHDOWN in line for line in lines): 252 ↛ 255line 252 didn't jump to line 255 because the condition on line 252 was always true

253 lines = list(adjust_pushdown_gen(lines, pushdown)) 

254 else: 

255 log.error(f'token ({TOKEN_ADJUSTED_PUSHDOWN}) not found - template mismatch') 

256 

257 if not layout['layout']['global']['has_changes']: 257 ↛ 258line 257 didn't jump to line 258 because the condition on line 257 was never true

258 log.info('removing changes from document layout') 

259 lines = list(too.remove_target_region_gen(lines, CUT_MARKER_CHANGES_TOP, CUT_MARKER_CHANGES_BOTTOM)) 

260 elif is_anonymized: 

261 log.info('removing named changes table skeleton from document layout') 

262 lines = list(too.remove_target_region_gen(lines, LAYOUT_NAMED_CUT_MARKER_TOP, LAYOUT_NAMED_CUT_MARKER_BOTTOM)) 

263 

264 if not layout['layout']['global']['has_notices']: 264 ↛ 265line 264 didn't jump to line 265 because the condition on line 264 was never true

265 log.info('removing notices from document layout') 

266 lines = list(too.remove_target_region_gen(lines, CUT_MARKER_NOTICES_TOP, CUT_MARKER_NOTICES_BOTTOM)) 

267 

268 log.info(LOG_SEPARATOR) 

269 log.info('weaving in the changes from {changes_path} ...') 

270 for n, line in enumerate(lines): 270 ↛ 283line 270 didn't jump to line 283 because the loop on line 270 didn't complete

271 if is_anonymized: 

272 if line.strip() == LAYOUT_ANONYMIZED_INSERT_MARKER: 

273 if is_version_semantics: 273 ↛ 274line 273 didn't jump to line 274 because the condition on line 273 was never true

274 table_text = TABLE_ANONYMOUS_VERSION_PRE + GLUE.join(rows) + TABLE_ANONYMOUS_VERSION_POST 

275 else: 

276 table_text = TABLE_ANONYMOUS_PRE + GLUE.join(rows) + TABLE_ANONYMOUS_POST 

277 lines[n] = table_text 

278 break 

279 else: 

280 if line.strip() == CHANGE_ROW_TOKEN: 

281 lines[n] = GLUE.join(rows) 

282 break 

283 if lines[-1]: 283 ↛ 284line 283 didn't jump to line 284 because the condition on line 283 was never true

284 lines.append('\n') 

285 with open(publisher_path, 'wt', encoding=ENCODING) as handle: 

286 handle.write('\n'.join(lines)) 

287 log.info(LOG_SEPARATOR) 

288 

289 return 0