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

1"""Command line interface for splice (Finnish liitos) contributions.""" 

2 

3import copy 

4import datetime as dti 

5import logging 

6import os 

7import pathlib 

8import platform 

9import platformdirs as pfd 

10import sys 

11 

12import typer 

13 

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) 

39 

40NA = 'n/a' 

41NL = '\n' 

42 

43app = typer.Typer( 

44 add_completion=False, 

45 context_settings={'help_option_names': ['-h', '--help']}, 

46 no_args_is_help=True, 

47) 

48 

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) 

119 

120 

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() 

137 

138 

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) 

157 

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', '', {} 

165 

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()})', '', {} 

174 

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 ) 

184 

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 ) 

191 

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 

206 

207 

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 

227 

228 return sys.exit( 

229 gat.verify(doc_root=doc, structure_name=structure, target_key=target, facet_key=facet, options=options) 

230 ) 

231 

232 

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 

252 

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 ) 

263 

264 

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 

284 

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 ) 

295 

296 

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 

317 

318 return sys.exit( 

319 cat.concatenate(doc_root=doc, structure_name=structure, target_key=target, facet_key=facet, options=options) 

320 ) 

321 

322 

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) 

368 

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) 

375 

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) 

385 

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) 

394 

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) 

403 

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) 

408 

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) 

419 

420 

421@app.command('report') 

422def report() -> int: 

423 """ 

424 Report on the environment. 

425 """ 

426 log.info(LOG_SEPARATOR) 

427 

428 def linux_distribution() -> object: 

429 try: 

430 return platform.linux_distribution() # type: ignore 

431 except AttributeError: 

432 return NA 

433 

434 def dist() -> object: 

435 try: 

436 return platform.dist() # type: ignore 

437 except AttributeError: 

438 return NA 

439 

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()}') 

451 

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) 

467 

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) 

472 

473 return sys.exit(0) 

474 

475 

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)) 

485 

486 

487@app.command('version') 

488def app_version() -> None: 

489 """ 

490 Display the application version and exit. 

491 """ 

492 callback(True)