Coverage for liitos/cli.py: 88.66%
194 statements
« prev ^ index » next coverage.py v7.10.2, created at 2025-08-11 15:51:33 +00:00
« prev ^ index » next coverage.py v7.10.2, created at 2025-08-11 15:51:33 +00:00
1"""Command line interface for splice (Finnish liitos) contributions."""
3import copy
4import datetime as dti
5import logging
6import os
7import pathlib
8import platform
9import platformdirs as pfd
10import sys
12import typer
14import liitos.approvals as sig
15import liitos.changes as chg
16import liitos.concat as cat
17import liitos.eject as eje
18import liitos.gather as gat
19import liitos.meta as met
20import liitos.render as ren
21import liitos.tools as too
22from liitos import (
23 APP_NAME,
24 APP_VERSION,
25 APPROVALS_STRATEGY,
26 DEBUG,
27 DEFAULT_STRUCTURE_NAME,
28 EXTERNALS,
29 FILTER_CS_LIST,
30 FROM_FORMAT_SPEC,
31 KNOWN_APPROVALS_STRATEGIES,
32 LOG_SEPARATOR,
33 QUIET,
34 TOOL_VERSION_COMMAND_MAP,
35 TS_FORMAT_PAYLOADS,
36 OptionsType,
37 log,
38)
40NA = 'n/a'
41NL = '\n'
43app = typer.Typer(
44 add_completion=False,
45 context_settings={'help_option_names': ['-h', '--help']},
46 no_args_is_help=True,
47)
49DocumentRoot = typer.Option(
50 '',
51 '-d',
52 '--document-root',
53 help='Root of the document tree to visit. Optional\n(default: positional tree root value)',
54)
55StructureName = typer.Option(
56 DEFAULT_STRUCTURE_NAME,
57 '-s',
58 '--structure',
59 help='structure mapping file (default: {gat.DEFAULT_STRUCTURE_NAME})',
60)
61TargetName = typer.Option(
62 '',
63 '-t',
64 '--target',
65 help='target document key',
66)
67FacetName = typer.Option(
68 '',
69 '-f',
70 '--facet',
71 help='facet key of target document',
72)
73FromFormatSpec = typer.Option(
74 FROM_FORMAT_SPEC,
75 '--from-format-spec',
76 help='from format specification handed over to pandoc',
77)
78FilterCSList = typer.Option(
79 'DEFAULT_FILTER',
80 '-F',
81 '--filters',
82 help='comma separated list of filters handed over to pandoc (in order) or empty to apply no filter',
83)
84Verbosity = typer.Option(
85 False,
86 '-v',
87 '--verbose',
88 help='Verbose output (default is False)',
89)
90Strictness = typer.Option(
91 False,
92 '--strict',
93 help='Ouput noisy warnings on console (default is False)',
94)
95OutputPath = typer.Option(
96 '',
97 '-o',
98 '--output-path',
99 help='Path to output unambiguous content to - like when ejecting a template',
100)
101LabelCall = typer.Option(
102 '',
103 '-l',
104 '--label',
105 help='optional label call to execute',
106)
107PatchTables = typer.Option(
108 False,
109 '-p',
110 '--patch-tables',
111 help='Patch tables EXPERIMENTAL (default is False)',
112)
113ApprovalsStrategy = typer.Option(
114 '',
115 '-a',
116 '--approvals-strategy',
117 help=f'optional approvals layout strategy in ({", ".join(KNOWN_APPROVALS_STRATEGIES)})',
118)
121@app.callback(invoke_without_command=True)
122def callback(
123 version: bool = typer.Option(
124 False,
125 '-V',
126 '--version',
127 help='Display the application version and exit',
128 is_eager=True,
129 )
130) -> None:
131 """
132 Splice (Finnish liitos) contributions.
133 """
134 if version:
135 typer.echo(f'{APP_NAME} version {APP_VERSION}')
136 raise typer.Exit()
139def _verify_call_vector(
140 doc_root: str,
141 doc_root_pos: str,
142 verbose: bool,
143 strict: bool,
144 label: str = '',
145 patch_tables: bool = False,
146 from_format_spec: str = FROM_FORMAT_SPEC,
147 filter_cs_list: str = '',
148 approvals_strategy: str = '',
149) -> tuple[int, str, str, OptionsType]:
150 """DRY"""
151 if DEBUG: 151 ↛ 152line 151 didn't jump to line 152 because the condition on line 151 was never true
152 logging.getLogger().setLevel(logging.DEBUG)
153 elif verbose:
154 logging.getLogger().setLevel(logging.INFO)
155 elif QUIET and not verbose and not strict: 155 ↛ 158line 155 didn't jump to line 158 because the condition on line 155 was always true
156 logging.getLogger().setLevel(logging.WARNING)
158 log.debug(f'verifier received: {locals()}')
159 doc = doc_root.strip()
160 if not doc and doc_root_pos:
161 doc = doc_root_pos
162 if not doc:
163 print('Document tree root required', file=sys.stderr)
164 return 2, 'Document tree root required', '', {}
166 doc_root_path = pathlib.Path(doc)
167 if doc_root_path.exists():
168 if not doc_root_path.is_dir(): 168 ↛ 169line 168 didn't jump to line 169 because the condition on line 168 was never true
169 print(f'requested tree root at ({doc}) is not a folder reachable from ({os.getcwd()})', file=sys.stderr)
170 return 2, f'requested tree root at ({doc}) is not a folder reachable from ({os.getcwd()})', '', {}
171 else:
172 print(f'requested tree root at ({doc}) does not exist as seen from ({os.getcwd()})', file=sys.stderr)
173 return 2, f'requested tree root at ({doc}) does not exist as seen from ({os.getcwd()})', '', {}
175 if not approvals_strategy:
176 approvals_strategy = APPROVALS_STRATEGY
177 log.info(f'Using value from environment for approvals strategy (APPROVALS_STRATEGY) == ({approvals_strategy})')
178 if not approvals_strategy: 178 ↛ 185line 178 didn't jump to line 185 because the condition on line 178 was always true
179 approvals_strategy = KNOWN_APPROVALS_STRATEGIES[0]
180 log.info(
181 'No preference in environment for approvals strategy (APPROVALS_STRATEGY)'
182 f' using default ({approvals_strategy})'
183 )
185 if approvals_strategy not in KNOWN_APPROVALS_STRATEGIES: 185 ↛ 186line 185 didn't jump to line 186 because the condition on line 185 was never true
186 approvals_strategy = KNOWN_APPROVALS_STRATEGIES[0]
187 log.info(
188 'Value in environment for approvals strategy (APPROVALS_STRATEGY)'
189 f' not in ({", ".join(KNOWN_APPROVALS_STRATEGIES)}) - using default ({approvals_strategy})'
190 )
192 options: OptionsType = {
193 'quiet': QUIET and not verbose and not strict,
194 'strict': strict,
195 'verbose': verbose,
196 'label': label,
197 'patch_tables': patch_tables,
198 'from_format_spec': from_format_spec if from_format_spec else FROM_FORMAT_SPEC,
199 'filter_cs_list': filter_cs_list if filter_cs_list != 'DEFAULT_FILTER' else FILTER_CS_LIST,
200 'approvals_strategy': approvals_strategy,
201 'table_caption_below': None,
202 'table_uglify': None,
203 }
204 log.debug(f'Post verifier: {options=}')
205 return 0, '', doc, options
208@app.command('verify')
209def verify( # noqa
210 doc_root_pos: str = typer.Argument(''),
211 doc_root: str = DocumentRoot,
212 structure: str = StructureName,
213 target: str = TargetName,
214 facet: str = FacetName,
215 verbose: bool = Verbosity,
216 strict: bool = Strictness,
217) -> int:
218 """
219 Verify the structure definition against the file system.
220 """
221 code, message, doc, options = _verify_call_vector(
222 doc_root=doc_root, doc_root_pos=doc_root_pos, verbose=verbose, strict=strict
223 )
224 if code:
225 log.error(message)
226 return code
228 return sys.exit(
229 gat.verify(doc_root=doc, structure_name=structure, target_key=target, facet_key=facet, options=options)
230 )
233@app.command('approvals')
234def approvals( # noqa
235 doc_root_pos: str = typer.Argument(''),
236 doc_root: str = DocumentRoot,
237 structure: str = StructureName,
238 target: str = TargetName,
239 facet: str = FacetName,
240 verbose: bool = Verbosity,
241 strict: bool = Strictness,
242) -> int:
243 """
244 Weave in the approvals for facet of target within document root.
245 """
246 code, message, doc, options = _verify_call_vector(
247 doc_root=doc_root, doc_root_pos=doc_root_pos, verbose=verbose, strict=strict
248 )
249 if code: 249 ↛ 253line 249 didn't jump to line 253 because the condition on line 249 was always true
250 log.error(message)
251 return 2
253 return sys.exit(
254 sig.weave(
255 doc_root=doc,
256 structure_name=structure,
257 target_key=target,
258 facet_key=facet,
259 options=options,
260 externals=EXTERNALS,
261 )
262 )
265@app.command('changes')
266def changes( # noqa
267 doc_root_pos: str = typer.Argument(''),
268 doc_root: str = DocumentRoot,
269 structure: str = StructureName,
270 target: str = TargetName,
271 facet: str = FacetName,
272 verbose: bool = Verbosity,
273 strict: bool = Strictness,
274) -> int:
275 """
276 Weave in the changes for facet of target within document root.
277 """
278 code, message, doc, options = _verify_call_vector(
279 doc_root=doc_root, doc_root_pos=doc_root_pos, verbose=verbose, strict=strict
280 )
281 if code: 281 ↛ 285line 281 didn't jump to line 285 because the condition on line 281 was always true
282 log.error(message)
283 return 2
285 return sys.exit(
286 chg.weave(
287 doc_root=doc,
288 structure_name=structure,
289 target_key=target,
290 facet_key=facet,
291 options=options,
292 externals=EXTERNALS,
293 )
294 )
297@app.command('concat')
298def concat( # noqa
299 *,
300 doc_root_pos: str = typer.Argument(''),
301 doc_root: str = DocumentRoot,
302 structure: str = StructureName,
303 target: str = TargetName,
304 facet: str = FacetName,
305 verbose: bool = Verbosity,
306 strict: bool = Strictness,
307) -> int:
308 """
309 Concatenate the markdown tree for facet of target within render/pdf below document root.
310 """
311 code, message, doc, options = _verify_call_vector(
312 doc_root=doc_root, doc_root_pos=doc_root_pos, verbose=verbose, strict=strict
313 )
314 if code: 314 ↛ 318line 314 didn't jump to line 318 because the condition on line 314 was always true
315 log.error(message)
316 return 2
318 return sys.exit(
319 cat.concatenate(doc_root=doc, structure_name=structure, target_key=target, facet_key=facet, options=options)
320 )
323@app.command('render')
324def render( # noqa
325 doc_root_pos: str = typer.Argument(''),
326 doc_root: str = DocumentRoot,
327 structure: str = StructureName,
328 target: str = TargetName,
329 facet: str = FacetName,
330 label: str = LabelCall,
331 verbose: bool = Verbosity,
332 strict: bool = Strictness,
333 patch_tables: bool = PatchTables,
334 from_format_spec: str = FromFormatSpec,
335 filter_cs_list: str = FilterCSList,
336 approvals_strategy: str = ApprovalsStrategy,
337) -> int:
338 """
339 Render the markdown tree for facet of target within render/pdf below document root.
340 \n
341 For ejected / customized templates set matching environment variables to the paths:\n
342 \n
343 - LIITOS_BOOKMATTER_TEMPLATE (for title page incl. approvals table)\n
344 - LIITOS_PUBLISHER_TEMPLATE (for publisher page incl. changes and proprietary info)\n
345 - LIITOS_METADATA_TEMPLATE (values to required known keys used on LaTeX level)\n
346 - LIITOS_SETUP_TEMPLATE (general layout template)\n
347 - DRIVER_TEMPLATE (template for general structure)\n
348 \n
349 To not insert PDF digital signature fields set the environment variable\n
350 LIITOS_NO_DIG_SIG_FIELDS to something truthy.\n
351 In case the LaTeX package digital-signature-fields is not found some placeholder\n
352 will be inserted when allowing the insert of such fields in the approvals table.
353 """
354 code, message, doc, options = _verify_call_vector(
355 doc_root=doc_root,
356 doc_root_pos=doc_root_pos,
357 verbose=verbose,
358 strict=strict,
359 label=label,
360 patch_tables=patch_tables,
361 from_format_spec=from_format_spec,
362 filter_cs_list=filter_cs_list,
363 approvals_strategy=approvals_strategy,
364 )
365 if code:
366 log.error(message)
367 return sys.exit(code)
369 start_time = dti.datetime.now(tz=dti.timezone.utc)
370 start_ts = start_time.strftime(TS_FORMAT_PAYLOADS)
371 log.info(f'Start timestamp ({start_ts})')
372 code = cat.concatenate(doc_root=doc, structure_name=structure, target_key=target, facet_key=facet, options=options)
373 if code: 373 ↛ 374line 373 didn't jump to line 374 because the condition on line 373 was never true
374 return sys.exit(code)
376 idem = os.getcwd()
377 doc = '../../'
378 log.info(f'before met.weave(): {os.getcwd()} set doc ({doc})')
379 externals = copy.deepcopy(EXTERNALS)
380 code = met.weave(
381 doc_root=doc, structure_name=structure, target_key=target, facet_key=facet, options=options, externals=externals
382 )
383 if code: 383 ↛ 384line 383 didn't jump to line 384 because the condition on line 383 was never true
384 return sys.exit(code)
386 log.info(f'before sig.weave(): {os.getcwd()} set doc ({doc})')
387 os.chdir(idem)
388 log.info(f'relocated for sig.weave(): {os.getcwd()} with doc ({doc})')
389 code = sig.weave(
390 doc_root=doc, structure_name=structure, target_key=target, facet_key=facet, options=options, externals=externals
391 )
392 if code: 392 ↛ 393line 392 didn't jump to line 393 because the condition on line 392 was never true
393 return sys.exit(code)
395 log.info(f'before chg.weave(): {os.getcwd()} set doc ({doc})')
396 os.chdir(idem)
397 log.info(f'relocated for chg.weave(): {os.getcwd()} with doc ({doc})')
398 code = chg.weave(
399 doc_root=doc, structure_name=structure, target_key=target, facet_key=facet, options=options, externals=externals
400 )
401 if code: 401 ↛ 402line 401 didn't jump to line 402 because the condition on line 401 was never true
402 return sys.exit(code)
404 log.info(f'before chg.weave(): {os.getcwd()} set doc ({doc})')
405 os.chdir(idem)
406 log.info(f'relocated for chg.weave(): {os.getcwd()} with doc ({doc})')
407 code = ren.der(doc_root=doc, structure_name=structure, target_key=target, facet_key=facet, options=options)
409 end_time = dti.datetime.now(tz=dti.timezone.utc)
410 end_ts = end_time.strftime(TS_FORMAT_PAYLOADS)
411 duration_secs = (end_time - start_time).total_seconds()
412 log.info(f'End timestamp ({end_ts})')
413 acted = 'Rendered'
414 if code == 0xFADECAFE: 414 ↛ 415line 414 didn't jump to line 415 because the condition on line 414 was never true
415 acted = 'Did not render'
416 code = 0 # HACK A DID ACK
417 log.info(f'{acted} {target} document for {facet} at {doc} in {duration_secs} secs')
418 return sys.exit(code)
421@app.command('report')
422def report() -> int:
423 """
424 Report on the environment.
425 """
426 log.info(LOG_SEPARATOR)
428 def linux_distribution() -> object:
429 try:
430 return platform.linux_distribution() # type: ignore
431 except AttributeError:
432 return NA
434 def dist() -> object:
435 try:
436 return platform.dist() # type: ignore
437 except AttributeError:
438 return NA
440 log.info('inspecting platform (machine, os, python, and user dirs):')
441 log.info(LOG_SEPARATOR)
442 log.info(f'- python.version: {sys.version.split(NL)}')
443 log.info(f'- dist: {str(dist())}')
444 log.info(f'- linux_distribution: {linux_distribution()}')
445 log.info(f'- system: {platform.system()}')
446 log.info(f'- machine: {platform.machine()}')
447 log.info(f'- platform: {platform.platform()}')
448 log.info(f'- uname: {platform.uname()}')
449 log.info(f'- version: {platform.version()}')
450 log.info(f'- mac_ver: {platform.mac_ver()}')
452 log.info(f'- user_data_dir: "{pfd.user_data_dir()}"')
453 log.info(f'- user_config_dir: "{pfd.user_config_dir()}"')
454 log.info(f'- user_cache_dir: "{pfd.user_cache_dir()}"')
455 log.info(f'- site_data_dir: "{pfd.site_data_dir()}"')
456 log.info(f'- site_config_dir: "{pfd.site_config_dir()}"')
457 log.info(f'- user_log_dir: "{pfd.user_log_dir()}"')
458 log.info(f'- user_documents_dir: "{pfd.user_documents_dir()}"')
459 log.info(f'- user_downloads_dir: "{pfd.user_downloads_dir()}"')
460 log.info(f'- user_pictures_dir: "{pfd.user_pictures_dir()}"')
461 log.info(f'- user_videos_dir: "{pfd.user_videos_dir()}"')
462 log.info(f'- user_music_dir: "{pfd.user_music_dir()}"')
463 log.info(f'- user_desktop_dir: "{pfd.user_desktop_dir()}"')
464 log.info(f'- user_runtime_dir: "{pfd.user_runtime_dir()}"')
465 log.info(f'- present_working_directory: "{os.getcwd()}"')
466 log.info(LOG_SEPARATOR)
468 log.info('inspecting environment (tool version information):')
469 for tool_key in TOOL_VERSION_COMMAND_MAP:
470 too.report(tool_key)
471 log.info(LOG_SEPARATOR)
473 return sys.exit(0)
476@app.command('eject')
477def eject( # noqa
478 that: str = typer.Argument(''),
479 out: str = OutputPath,
480) -> int:
481 """
482 Eject a template. Enter unique part to retrieve, any unknown word to obtain the list of known templates.
483 """
484 return sys.exit(eje.this(thing=that, out=out))
487@app.command('version')
488def app_version() -> None:
489 """
490 Display the application version and exit.
491 """
492 callback(True)