Coverage for liitos/changes.py: 90.12%

134 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2025-06-28 20:14:46 +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) -> list[str]: 

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

115 return COLUMNS_EXPECTED 

116 

117 

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

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

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

121 

122 

123@no_type_check 

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

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

126 

127 On error an empty logical model is returned. 

128 """ 

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

130 model = sorted(change.keys()) 

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

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

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

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

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

136 return [] 

137 

138 model = [] 

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

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

141 issue = change['issue'] 

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

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

144 summary = change['summary'] 

145 model.append( 

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

147 ) 

148 

149 return model 

150 

151 

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

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

154 for line in text_lines: 

155 if TOKEN_ADJUSTED_PUSHDOWN in line: 

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

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

158 yield line 

159 

160 

161def weave( 

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

163 structure_name: str, 

164 target_key: str, 

165 facet_key: str, 

166 options: OptionsType, 

167 externals: ExternalsType, 

168) -> int: 

169 """Later alligator.""" 

170 log.info(LOG_SEPARATOR) 

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

172 structure, asset_map = gat.prelude( 

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

174 ) 

175 

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

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

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

179 

180 log.info(LOG_SEPARATOR) 

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

182 columns_expected = derive_model(changes_path) 

183 log.info(f'weaving changes in from ({changes_path})') 

184 

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

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

187 if not changes[0] and ' json ' in changes[1]: 

188 log.error(changes[1]) 

189 return 2 

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

191 

192 log.info(LOG_SEPARATOR) 

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

194 

195 logical_model = normalize(changes, columns_expected=columns_expected) 

196 

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

198 is_version_semantics = False 

199 if is_anonymized: 

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

201 is_version_semantics = True 

202 rows = [ 

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

204 for kv in logical_model 

205 ] 

206 else: 

207 rows = [ 

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

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

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

211 for kv in logical_model 

212 ] 

213 else: 

214 rows = [ 

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

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

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

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

219 for kv in logical_model 

220 ] 

221 

222 pushdown = DEFAULT_ADJUSTED_PUSHDOWN_VALUE 

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

224 

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

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

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

228 

229 publisher_template = tpl.load_resource(publisher_template, publisher_template_is_custom) 

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

231 

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

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

234 else: 

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

236 

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

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

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

240 elif is_anonymized: 

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

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

243 

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

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

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

247 

248 log.info(LOG_SEPARATOR) 

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

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

251 if is_anonymized: 

252 if line.strip() == LAYOUT_ANONYMIZED_INSERT_MARKER: 

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

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

255 else: 

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

257 lines[n] = table_text 

258 break 

259 else: 

260 if line.strip() == CHANGE_ROW_TOKEN: 

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

262 break 

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

264 lines.append('\n') 

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

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

267 log.info(LOG_SEPARATOR) 

268 

269 return 0