Coverage for liitos/cli.py: 88.70%

195 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-08-31 13:07:35 +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='Output 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) 

119Forcedness = typer.Option( 

120 False, 

121 '--force', 

122 help='Force rendering regardless of render value in structure files (default is False)', 

123) 

124 

125 

126@app.callback(invoke_without_command=True) 

127def callback( 

128 version: bool = typer.Option( 

129 False, 

130 '-V', 

131 '--version', 

132 help='Display the application version and exit', 

133 is_eager=True, 

134 ) 

135) -> None: 

136 """ 

137 Splice (Finnish liitos) contributions. 

138 """ 

139 if version: 

140 typer.echo(f'{APP_NAME} version {APP_VERSION}') 

141 raise typer.Exit() 

142 

143 

144def _verify_call_vector( 

145 doc_root: str, 

146 doc_root_pos: str, 

147 verbose: bool, 

148 strict: bool, 

149 label: str = '', 

150 patch_tables: bool = False, 

151 from_format_spec: str = FROM_FORMAT_SPEC, 

152 filter_cs_list: str = '', 

153 approvals_strategy: str = '', 

154 force: bool = False, 

155) -> tuple[int, str, str, OptionsType]: 

156 """DRY""" 

157 if DEBUG: 157 ↛ 158line 157 didn't jump to line 158 because the condition on line 157 was never true

158 logging.getLogger().setLevel(logging.DEBUG) 

159 elif verbose: 

160 logging.getLogger().setLevel(logging.INFO) 

161 elif QUIET and not verbose and not strict: 161 ↛ 164line 161 didn't jump to line 164 because the condition on line 161 was always true

162 logging.getLogger().setLevel(logging.WARNING) 

163 

164 log.debug(f'verifier received: {locals()}') 

165 doc = doc_root.strip() 

166 if not doc and doc_root_pos: 

167 doc = doc_root_pos 

168 if not doc: 

169 print('Document tree root required', file=sys.stderr) 

170 return 2, 'Document tree root required', '', {} 

171 

172 doc_root_path = pathlib.Path(doc) 

173 if doc_root_path.exists(): 

174 if not doc_root_path.is_dir(): 174 ↛ 175line 174 didn't jump to line 175 because the condition on line 174 was never true

175 print(f'requested tree root at ({doc}) is not a folder reachable from ({os.getcwd()})', file=sys.stderr) 

176 return 2, f'requested tree root at ({doc}) is not a folder reachable from ({os.getcwd()})', '', {} 

177 else: 

178 print(f'requested tree root at ({doc}) does not exist as seen from ({os.getcwd()})', file=sys.stderr) 

179 return 2, f'requested tree root at ({doc}) does not exist as seen from ({os.getcwd()})', '', {} 

180 

181 if not approvals_strategy: 

182 approvals_strategy = APPROVALS_STRATEGY 

183 log.info(f'Using value from environment for approvals strategy (APPROVALS_STRATEGY) == ({approvals_strategy})') 

184 if not approvals_strategy: 184 ↛ 191line 184 didn't jump to line 191 because the condition on line 184 was always true

185 approvals_strategy = KNOWN_APPROVALS_STRATEGIES[0] 

186 log.info( 

187 'No preference in environment for approvals strategy (APPROVALS_STRATEGY)' 

188 f' using default ({approvals_strategy})' 

189 ) 

190 

191 if approvals_strategy not in KNOWN_APPROVALS_STRATEGIES: 191 ↛ 192line 191 didn't jump to line 192 because the condition on line 191 was never true

192 approvals_strategy = KNOWN_APPROVALS_STRATEGIES[0] 

193 log.info( 

194 'Value in environment for approvals strategy (APPROVALS_STRATEGY)' 

195 f' not in ({", ".join(KNOWN_APPROVALS_STRATEGIES)}) - using default ({approvals_strategy})' 

196 ) 

197 

198 options: OptionsType = { 

199 'force': force, 

200 'quiet': QUIET and not verbose and not strict, 

201 'strict': strict, 

202 'verbose': verbose, 

203 'label': label, 

204 'patch_tables': patch_tables, 

205 'from_format_spec': from_format_spec if from_format_spec else FROM_FORMAT_SPEC, 

206 'filter_cs_list': filter_cs_list if filter_cs_list != 'DEFAULT_FILTER' else FILTER_CS_LIST, 

207 'approvals_strategy': approvals_strategy, 

208 'table_caption_below': None, 

209 'table_uglify': None, 

210 } 

211 log.debug(f'Post verifier: {options=}') 

212 return 0, '', doc, options 

213 

214 

215@app.command('verify') 

216def verify( # noqa 

217 doc_root_pos: str = typer.Argument(''), 

218 doc_root: str = DocumentRoot, 

219 structure: str = StructureName, 

220 target: str = TargetName, 

221 facet: str = FacetName, 

222 verbose: bool = Verbosity, 

223 strict: bool = Strictness, 

224) -> int: 

225 """ 

226 Verify the structure definition against the file system. 

227 """ 

228 code, message, doc, options = _verify_call_vector( 

229 doc_root=doc_root, doc_root_pos=doc_root_pos, verbose=verbose, strict=strict 

230 ) 

231 if code: 

232 log.error(message) 

233 return code 

234 

235 return sys.exit( 

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

237 ) 

238 

239 

240@app.command('approvals') 

241def approvals( # noqa 

242 doc_root_pos: str = typer.Argument(''), 

243 doc_root: str = DocumentRoot, 

244 structure: str = StructureName, 

245 target: str = TargetName, 

246 facet: str = FacetName, 

247 verbose: bool = Verbosity, 

248 strict: bool = Strictness, 

249) -> int: 

250 """ 

251 Weave in the approvals for facet of target within document root. 

252 """ 

253 code, message, doc, options = _verify_call_vector( 

254 doc_root=doc_root, doc_root_pos=doc_root_pos, verbose=verbose, strict=strict 

255 ) 

256 if code: 256 ↛ 260line 256 didn't jump to line 260 because the condition on line 256 was always true

257 log.error(message) 

258 return 2 

259 

260 return sys.exit( 

261 sig.weave( 

262 doc_root=doc, 

263 structure_name=structure, 

264 target_key=target, 

265 facet_key=facet, 

266 options=options, 

267 externals=EXTERNALS, 

268 ) 

269 ) 

270 

271 

272@app.command('changes') 

273def changes( # noqa 

274 doc_root_pos: str = typer.Argument(''), 

275 doc_root: str = DocumentRoot, 

276 structure: str = StructureName, 

277 target: str = TargetName, 

278 facet: str = FacetName, 

279 verbose: bool = Verbosity, 

280 strict: bool = Strictness, 

281) -> int: 

282 """ 

283 Weave in the changes for facet of target within document root. 

284 """ 

285 code, message, doc, options = _verify_call_vector( 

286 doc_root=doc_root, doc_root_pos=doc_root_pos, verbose=verbose, strict=strict 

287 ) 

288 if code: 288 ↛ 292line 288 didn't jump to line 292 because the condition on line 288 was always true

289 log.error(message) 

290 return 2 

291 

292 return sys.exit( 

293 chg.weave( 

294 doc_root=doc, 

295 structure_name=structure, 

296 target_key=target, 

297 facet_key=facet, 

298 options=options, 

299 externals=EXTERNALS, 

300 ) 

301 ) 

302 

303 

304@app.command('concat') 

305def concat( # noqa 

306 *, 

307 doc_root_pos: str = typer.Argument(''), 

308 doc_root: str = DocumentRoot, 

309 structure: str = StructureName, 

310 target: str = TargetName, 

311 facet: str = FacetName, 

312 verbose: bool = Verbosity, 

313 strict: bool = Strictness, 

314 force: bool = Forcedness, 

315) -> int: 

316 """ 

317 Concatenate the markdown tree for facet of target within render/pdf below document root. 

318 """ 

319 code, message, doc, options = _verify_call_vector( 

320 doc_root=doc_root, doc_root_pos=doc_root_pos, verbose=verbose, strict=strict, force=force 

321 ) 

322 if code: 322 ↛ 326line 322 didn't jump to line 326 because the condition on line 322 was always true

323 log.error(message) 

324 return 2 

325 

326 return sys.exit( 

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

328 ) 

329 

330 

331@app.command('render') 

332def render( # noqa 

333 doc_root_pos: str = typer.Argument(''), 

334 doc_root: str = DocumentRoot, 

335 structure: str = StructureName, 

336 target: str = TargetName, 

337 facet: str = FacetName, 

338 label: str = LabelCall, 

339 verbose: bool = Verbosity, 

340 strict: bool = Strictness, 

341 patch_tables: bool = PatchTables, 

342 from_format_spec: str = FromFormatSpec, 

343 filter_cs_list: str = FilterCSList, 

344 approvals_strategy: str = ApprovalsStrategy, 

345 force: bool = Forcedness, 

346) -> int: 

347 """ 

348 Render the markdown tree for facet of target within render/pdf below document root. 

349 \n 

350 For ejected / customized templates set matching environment variables to the paths:\n 

351 \n 

352 - LIITOS_BOOKMATTER_TEMPLATE (for title page incl. approvals table)\n 

353 - LIITOS_PUBLISHER_TEMPLATE (for publisher page incl. changes and proprietary info)\n 

354 - LIITOS_METADATA_TEMPLATE (values to required known keys used on LaTeX level)\n 

355 - LIITOS_SETUP_TEMPLATE (general layout template)\n 

356 - DRIVER_TEMPLATE (template for general structure)\n 

357 \n 

358 To not insert PDF digital signature fields set the environment variable\n 

359 LIITOS_NO_DIG_SIG_FIELDS to something truthy.\n 

360 In case the LaTeX package digital-signature-fields is not found some placeholder\n 

361 will be inserted when allowing the insert of such fields in the approvals table. 

362 """ 

363 code, message, doc, options = _verify_call_vector( 

364 doc_root=doc_root, 

365 doc_root_pos=doc_root_pos, 

366 verbose=verbose, 

367 strict=strict, 

368 label=label, 

369 patch_tables=patch_tables, 

370 from_format_spec=from_format_spec, 

371 filter_cs_list=filter_cs_list, 

372 approvals_strategy=approvals_strategy, 

373 force=force, 

374 ) 

375 if code: 

376 log.error(message) 

377 return sys.exit(code) 

378 

379 start_time = dti.datetime.now(tz=dti.timezone.utc) 

380 start_ts = start_time.strftime(TS_FORMAT_PAYLOADS) 

381 log.info(f'Start timestamp ({start_ts})') 

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

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 idem = os.getcwd() 

387 doc = '../../' 

388 log.info(f'before met.weave(): {os.getcwd()} set doc ({doc})') 

389 externals = copy.deepcopy(EXTERNALS) 

390 code = met.weave( 

391 doc_root=doc, structure_name=structure, target_key=target, facet_key=facet, options=options, externals=externals 

392 ) 

393 if code: 393 ↛ 394line 393 didn't jump to line 394 because the condition on line 393 was never true

394 return sys.exit(code) 

395 

396 log.info(f'before sig.weave(): {os.getcwd()} set doc ({doc})') 

397 os.chdir(idem) 

398 log.info(f'relocated for sig.weave(): {os.getcwd()} with doc ({doc})') 

399 code = sig.weave( 

400 doc_root=doc, structure_name=structure, target_key=target, facet_key=facet, options=options, externals=externals 

401 ) 

402 if code: 402 ↛ 403line 402 didn't jump to line 403 because the condition on line 402 was never true

403 return sys.exit(code) 

404 

405 log.info(f'before chg.weave(): {os.getcwd()} set doc ({doc})') 

406 os.chdir(idem) 

407 log.info(f'relocated for chg.weave(): {os.getcwd()} with doc ({doc})') 

408 code = chg.weave( 

409 doc_root=doc, structure_name=structure, target_key=target, facet_key=facet, options=options, externals=externals 

410 ) 

411 if code: 411 ↛ 412line 411 didn't jump to line 412 because the condition on line 411 was never true

412 return sys.exit(code) 

413 

414 log.info(f'before chg.weave(): {os.getcwd()} set doc ({doc})') 

415 os.chdir(idem) 

416 log.info(f'relocated for chg.weave(): {os.getcwd()} with doc ({doc})') 

417 code = ren.der(doc_root=doc, structure_name=structure, target_key=target, facet_key=facet, options=options) 

418 

419 end_time = dti.datetime.now(tz=dti.timezone.utc) 

420 end_ts = end_time.strftime(TS_FORMAT_PAYLOADS) 

421 duration_secs = (end_time - start_time).total_seconds() 

422 log.info(f'End timestamp ({end_ts})') 

423 acted = 'Rendered' 

424 if code == 0xFADECAFE: 424 ↛ 425line 424 didn't jump to line 425 because the condition on line 424 was never true

425 acted = 'Did not render' 

426 code = 0 # HACK A DID ACK 

427 log.info(f'{acted} {target} document for {facet} at {doc} in {duration_secs} secs') 

428 return sys.exit(code) 

429 

430 

431@app.command('report') 

432def report() -> int: 

433 """ 

434 Report on the environment. 

435 """ 

436 log.info(LOG_SEPARATOR) 

437 

438 def linux_distribution() -> object: 

439 try: 

440 return platform.linux_distribution() # type: ignore 

441 except AttributeError: 

442 return NA 

443 

444 def dist() -> object: 

445 try: 

446 return platform.dist() # type: ignore 

447 except AttributeError: 

448 return NA 

449 

450 log.info('inspecting platform (machine, os, python, and user dirs):') 

451 log.info(LOG_SEPARATOR) 

452 log.info(f'- python.version: {sys.version.split(NL)}') 

453 log.info(f'- dist: {str(dist())}') 

454 log.info(f'- linux_distribution: {linux_distribution()}') 

455 log.info(f'- system: {platform.system()}') 

456 log.info(f'- machine: {platform.machine()}') 

457 log.info(f'- platform: {platform.platform()}') 

458 log.info(f'- uname: {platform.uname()}') 

459 log.info(f'- version: {platform.version()}') 

460 log.info(f'- mac_ver: {platform.mac_ver()}') 

461 

462 log.info(f'- user_data_dir: "{pfd.user_data_dir()}"') 

463 log.info(f'- user_config_dir: "{pfd.user_config_dir()}"') 

464 log.info(f'- user_cache_dir: "{pfd.user_cache_dir()}"') 

465 log.info(f'- site_data_dir: "{pfd.site_data_dir()}"') 

466 log.info(f'- site_config_dir: "{pfd.site_config_dir()}"') 

467 log.info(f'- user_log_dir: "{pfd.user_log_dir()}"') 

468 log.info(f'- user_documents_dir: "{pfd.user_documents_dir()}"') 

469 log.info(f'- user_downloads_dir: "{pfd.user_downloads_dir()}"') 

470 log.info(f'- user_pictures_dir: "{pfd.user_pictures_dir()}"') 

471 log.info(f'- user_videos_dir: "{pfd.user_videos_dir()}"') 

472 log.info(f'- user_music_dir: "{pfd.user_music_dir()}"') 

473 log.info(f'- user_desktop_dir: "{pfd.user_desktop_dir()}"') 

474 log.info(f'- user_runtime_dir: "{pfd.user_runtime_dir()}"') 

475 log.info(f'- present_working_directory: "{os.getcwd()}"') 

476 log.info(LOG_SEPARATOR) 

477 

478 log.info('inspecting environment (tool version information):') 

479 for tool_key in TOOL_VERSION_COMMAND_MAP: 

480 too.report(tool_key) 

481 log.info(LOG_SEPARATOR) 

482 

483 return sys.exit(0) 

484 

485 

486@app.command('eject') 

487def eject( # noqa 

488 that: str = typer.Argument(''), 

489 out: str = OutputPath, 

490) -> int: 

491 """ 

492 Eject a template. Enter unique part to retrieve, any unknown word to obtain the list of known templates. 

493 """ 

494 return sys.exit(eje.this(thing=that, out=out)) 

495 

496 

497@app.command('version') 

498def app_version() -> None: 

499 """ 

500 Display the application version and exit. 

501 """ 

502 callback(True)