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
« 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).
3# Supported Table Layouts
5# Layout `named` (the old default)
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? |
13Table: The named table is simple to grow by appending rows.
15The named layout relies on the skeleton in the pubisher.tex.in template.
17# Layout `anonymous` (the new default)
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? |
25Table: This anonymous table is also simple to grow by appending rows.
27The anonymous layout requires more dynamic LaTeX generation and thus generates the construct
28from the data inside this module.
29"""
31import pathlib
32from typing import Generator, Union, no_type_check
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
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
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 --|'
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"""
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"""
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.
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
109 log.info('using default layout for approvals')
110 return layout
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
118 return channel, columns_expected
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)
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.
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 []
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 []
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
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 )
172 return model
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
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 )
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=}')
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})')
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=}')
212 log.info(LOG_SEPARATOR)
213 log.info('plausibility tests for changes ...')
215 logical_model = normalize(changes, channel=channel, columns_expected=columns_expected)
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 ]
242 pushdown = DEFAULT_ADJUSTED_PUSHDOWN_VALUE
243 log.info(f'calculated adjusted pushdown to be {pushdown}em')
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')
249 publisher_template = tpl.load_resource(publisher_template, publisher_template_is_custom)
250 lines = [line.rstrip() for line in publisher_template.split(NL)]
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')
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))
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))
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)
289 return 0