Coverage for liitos/changes.py: 91.79%

140 statements  

« prev     ^ index     » next       coverage.py v7.6.0, created at 2024-07-16 00:43:25 +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 os 

32import pathlib 

33from typing import Generator, Union, no_type_check 

34 

35import liitos.gather as gat 

36import liitos.template as tpl 

37import liitos.tools as too 

38from liitos import ENCODING, LOG_SEPARATOR, PathLike, log 

39 

40 

41PUBLISHER_TEMPLATE = os.getenv('LIITOS_PUBLISHER_TEMPLATE', '') 

42PUBLISHER_TEMPLATE_IS_EXTERNAL = bool(PUBLISHER_TEMPLATE) 

43if not PUBLISHER_TEMPLATE: 43 ↛ 46line 43 didn't jump to line 46 because the condition on line 43 was always true

44 PUBLISHER_TEMPLATE = 'templates/publisher.tex.in' 

45 

46PUBLISHER_PATH = pathlib.Path('render/pdf/publisher.tex') 

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

48DEFAULT_REVISION = '00' 

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

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

51GLUE = '\n\\hline\n' 

52JSON_CHANNEL = 'json' 

53YAML_CHANNEL = 'yaml' 

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

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

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

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

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

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

60TOKEN_ADJUSTED_PUSHDOWN = r'\AdustedPushdown' # nosec B105 

61DEFAULT_ADJUSTED_PUSHDOWN_VALUE = 14 

62 

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

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

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

66 

67NL = '\n' 

68TABLE_ANONYMOUS_PRE = r"""\ 

69\begin{longtable}[]{| 

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

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

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

73\hline 

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

75\textbf{\theChangeLogIssLabel} 

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

77\textbf{\theChangeLogRevLabel} 

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

79\textbf{\theChangeLogDescLabel} 

80\end{minipage} \\ 

81\hline 

82""" 

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

84TABLE_ANONYMOUS_POST = r"""\hline 

85\end{longtable} 

86""" 

87 

88 

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

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

91 

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

93 """ 

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

95 if layout_path: 

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

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

98 

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

100 return layout 

101 

102 

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

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

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

106 columns_expected = COLUMNS_MINIMAL if channel == JSON_CHANNEL else COLUMNS_EXPECTED 

107 

108 return channel, columns_expected 

109 

110 

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

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

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

114 

115 

116@no_type_check 

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

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

119 

120 On error an empty logical model is returned. 

121 """ 

122 if channel == JSON_CHANNEL: 

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

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

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

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

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

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

129 return [] 

130 

131 if channel == YAML_CHANNEL: 

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

133 model = sorted(change.keys()) 

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

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: ({model}) in slot {slot}') 

139 return [] 

140 

141 model = [] 

142 if channel == JSON_CHANNEL: 

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

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

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

146 model.append({'issue': issue, 'revision': revision, 'author': author, 'summary': summary}) 

147 return model 

148 

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

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

151 issue = change['issue'] 

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

153 summary = change['summary'] 

154 model.append({'issue': issue, 'revision': revision, 'author': author, 'summary': summary}) 

155 

156 return model 

157 

158 

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

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

161 for line in text_lines: 

162 if TOKEN_ADJUSTED_PUSHDOWN in line: 

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

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

165 yield line 

166 

167 

168def weave( 

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

170 structure_name: str, 

171 target_key: str, 

172 facet_key: str, 

173 options: dict[str, Union[bool, str]], 

174) -> int: 

175 """Later alligator.""" 

176 log.info(LOG_SEPARATOR) 

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

178 structure, asset_map = gat.prelude( 

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

180 ) 

181 

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

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

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

185 

186 log.info(LOG_SEPARATOR) 

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

188 channel, columns_expected = derive_model(changes_path) 

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

190 

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

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

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

194 

195 log.info(LOG_SEPARATOR) 

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

197 

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

199 

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

201 if is_anonymized: 

202 rows = [ 

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

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

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

206 for kv in logical_model 

207 ] 

208 else: 

209 rows = [ 

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

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

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

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

214 for kv in logical_model 

215 ] 

216 

217 pushdown = DEFAULT_ADJUSTED_PUSHDOWN_VALUE 

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

219 

220 publisher_template = tpl.load_resource(PUBLISHER_TEMPLATE, PUBLISHER_TEMPLATE_IS_EXTERNAL) 

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

222 

223 if any(TOKEN_ADJUSTED_PUSHDOWN in line for line in lines): 223 ↛ exit,   223 ↛ 2262 missed branches: 1) line 223 didn't finish the generator expression on line 223, 2) line 223 didn't jump to line 226 because the condition on line 223 was always true

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

225 else: 

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

227 

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

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

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

231 elif is_anonymized: 

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

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

234 

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

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

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

238 

239 log.info(LOG_SEPARATOR) 

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

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

242 if is_anonymized: 

243 if line.strip() == LAYOUT_ANONYMIZED_INSERT_MARKER: 

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

245 lines[n] = table_text 

246 break 

247 else: 

248 if line.strip() == CHANGE_ROW_TOKEN: 

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

250 break 

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

252 lines.append('\n') 

253 with open(PUBLISHER_PATH, 'wt', encoding=ENCODING) as handle: 

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

255 log.info(LOG_SEPARATOR) 

256 

257 return 0