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
« 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).
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) -> list[str]:
114 """Derive the model as channel type and column model from the given path."""
115 return COLUMNS_EXPECTED
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)
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.
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 []
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 )
149 return model
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
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 )
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=}')
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})')
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=}')
192 log.info(LOG_SEPARATOR)
193 log.info('plausibility tests for changes ...')
195 logical_model = normalize(changes, columns_expected=columns_expected)
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 ]
222 pushdown = DEFAULT_ADJUSTED_PUSHDOWN_VALUE
223 log.info(f'calculated adjusted pushdown to be {pushdown}em')
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')
229 publisher_template = tpl.load_resource(publisher_template, publisher_template_is_custom)
230 lines = [line.rstrip() for line in publisher_template.split(NL)]
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')
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))
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))
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)
269 return 0