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
« 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).
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 os
32import pathlib
33from typing import Generator, Union, no_type_check
35import liitos.gather as gat
36import liitos.template as tpl
37import liitos.tools as too
38from liitos import ENCODING, LOG_SEPARATOR, PathLike, log
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'
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
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 --|'
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"""
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.
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
99 log.info('using default layout for approvals')
100 return layout
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
108 return channel, columns_expected
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)
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.
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 []
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 []
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
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})
156 return model
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
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 )
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=}')
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})')
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=}')
195 log.info(LOG_SEPARATOR)
196 log.info('plausibility tests for changes ...')
198 logical_model = normalize(changes, channel=channel, columns_expected=columns_expected)
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 ]
217 pushdown = DEFAULT_ADJUSTED_PUSHDOWN_VALUE
218 log.info(f'calculated adjusted pushdown to be {pushdown}em')
220 publisher_template = tpl.load_resource(PUBLISHER_TEMPLATE, PUBLISHER_TEMPLATE_IS_EXTERNAL)
221 lines = [line.rstrip() for line in publisher_template.split(NL)]
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')
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))
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))
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)
257 return 0