Coverage for stempelwerk / StempelWerk.py: 96%

370 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-03 21:20 +0100

1#! /usr/bin/env python3 

2 

3# ---------------------------------------------------------------------------- 

4# 

5# StempelWerk 

6# =========== 

7# Automatic code generation from Jinja2 templates 

8# 

9# Copyright (c) 2020-2026 Martin Zuther (https://www.mzuther.de/) 

10# 

11# Redistribution and use in source and binary forms, with or without 

12# modification, are permitted provided that the following conditions 

13# are met: 

14# 

15# 1. Redistributions of source code must retain the above copyright 

16# notice, this list of conditions and the following disclaimer. 

17# 

18# 2. Redistributions in binary form must reproduce the above 

19# copyright notice, this list of conditions and the following 

20# disclaimer in the documentation and/or other materials provided 

21# with the distribution. 

22# 

23# 3. Neither the name of the copyright holder nor the names of its 

24# contributors may be used to endorse or promote products derived 

25# from this software without specific prior written permission. 

26# 

27# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 

28# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 

29# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 

30# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 

31# COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, 

32# INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 

33# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 

34# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) 

35# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, 

36# STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 

37# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED 

38# OF THE POSSIBILITY OF SUCH DAMAGE. 

39# 

40# Thank you for using free software! 

41# 

42# ---------------------------------------------------------------------------- 

43 

44import argparse 

45import copy 

46import dataclasses 

47import datetime 

48import importlib 

49import json 

50import math 

51import os 

52import pathlib 

53import sys 

54 

55import jinja2 

56from herkules.Herkules import herkules 

57 

58__version__ = '1.1.1' 

59 

60 

61class StempelWerk: 

62 APPLICATION = 'StempelWerk' 

63 AUTHOR = 'Martin Zuther' 

64 DESCRIPTION = 'Automatic code generation from Jinja2 templates.' 

65 LICENSE = 'BSD 3-Clause License' 

66 

67 APPLICATION_VERSION = f'{APPLICATION} v{__version__}' 

68 COPYRIGHT = f'{APPLICATION_VERSION:20} (c) 2020-2026 {AUTHOR}' 

69 

70 # a lower verbosity value means less output on the console 

71 VERBOSITY_HIGH = 1 

72 VERBOSITY_NORMAL = 0 

73 VERBOSITY_LOW = -1 

74 VERBOSITY_VERY_LOW = -2 

75 

76 @staticmethod 

77 def format_version( 

78 verbosity=VERBOSITY_NORMAL, 

79 ): 

80 if verbosity < StempelWerk.VERBOSITY_NORMAL: # pragma: no coverage 

81 return StempelWerk.APPLICATION_VERSION 

82 else: 

83 return ( 

84 StempelWerk.COPYRIGHT 

85 + '\n' 

86 + f'Licensed under the {StempelWerk.LICENSE}' 

87 ) 

88 

89 @staticmethod 

90 def format_description( 

91 verbosity=VERBOSITY_NORMAL, 

92 ): 

93 return ( 

94 StempelWerk.format_version(verbosity) 

95 + '\n\n' 

96 + StempelWerk.DESCRIPTION 

97 ) 

98 

99 def _display_version( 

100 self, 

101 verbosity=VERBOSITY_NORMAL, 

102 ): 

103 version_message = self.format_version(verbosity) 

104 

105 print() 

106 if verbosity < self.VERBOSITY_NORMAL: # pragma: no coverage 

107 print(version_message) 

108 else: 

109 for line in version_message.split('\n'): 

110 print(f'[ {line:<48} ]') 

111 print() 

112 

113 # --------------------------------------------------------------------- 

114 

115 class LinePrinter: 

116 def __init__( 

117 self, 

118 verbosity, 

119 ): 

120 self.verbosity = verbosity 

121 

122 def _print_context( 

123 self, 

124 context, 

125 message, 

126 ): 

127 if message: 

128 message = f'{context}: {message}' 

129 print(message) 

130 

131 def error( 

132 self, 

133 message='', 

134 ): 

135 self._print_context('ERROR', message) 

136 

137 def debug( 

138 self, 

139 message='', 

140 ): 

141 if ( 

142 self.verbosity > StempelWerk.VERBOSITY_NORMAL 

143 ): # pragma: no coverage 

144 self._print_context('DEBUG', message) 

145 

146 # --------------------------------------------------------------------- 

147 

148 # Auto-create settings class to write leaner code 

149 # 

150 # The "@dataclass" decorator creates a class, class members, and a 

151 # constructor with key-word parameters that have default values. 

152 # 

153 # In addition, this allows us to address settings with the more readable 

154 # membership operator ("settings.template_dir") instead of using dictionary 

155 # access ("settings['template_dir']"). 

156 @dataclasses.dataclass 

157 class Settings: 

158 root_dir: str 

159 template_dir: str 

160 output_dir: str 

161 # ---------------------------------------- 

162 included_file_names: list 

163 stencil_dir_name: str = '' 

164 create_directories: bool = False 

165 # ---------------------------------------- 

166 global_namespace: list = dataclasses.field(default_factory=dict) 

167 jinja_options: list = dataclasses.field(default_factory=dict) 

168 jinja_extensions: list = dataclasses.field(default_factory=list) 

169 custom_modules: list = dataclasses.field(default_factory=list) 

170 # ---------------------------------------- 

171 last_run_file: str = '.last_run' 

172 marker_new_file: str = '### New file:' 

173 marker_content: str = '### Content:' 

174 newline: str = None 

175 

176 @staticmethod 

177 def finalize_path( 

178 root_dir, 

179 original_path, 

180 ): 

181 root_dir = pathlib.Path(root_dir) 

182 original_path = original_path.strip() 

183 

184 new_path = root_dir / original_path 

185 

186 return new_path.expanduser() 

187 

188 def __post_init__( 

189 self, 

190 ): 

191 # root directory is relative to the current directory 

192 current_dir = pathlib.Path.cwd() 

193 

194 self.root_dir = self.finalize_path( 

195 current_dir, 

196 self.root_dir, 

197 ) 

198 

199 # all other paths are relative to the root directory 

200 self.template_dir = self.finalize_path( 

201 self.root_dir, 

202 self.template_dir, 

203 ) 

204 

205 self.output_dir = self.finalize_path( 

206 self.root_dir, 

207 self.output_dir, 

208 ) 

209 

210 self.last_run_file = self.finalize_path( 

211 self.root_dir, 

212 self.last_run_file, 

213 ) 

214 

215 def __str__( 

216 self, 

217 ): 

218 output = [] 

219 separator = ' ' 

220 

221 settings = [ 

222 'root_dir', 

223 'template_dir', 

224 'output_dir', 

225 separator, 

226 'included_file_names', 

227 'stencil_dir_name', 

228 'create_directories', 

229 separator, 

230 'global_namespace', 

231 'jinja_options', 

232 'jinja_extensions', 

233 'custom_modules', 

234 separator, 

235 'last_run_file', 

236 'marker_new_file', 

237 'marker_content', 

238 'newline', 

239 ] 

240 

241 for setting in settings: 

242 if setting == separator: 

243 output.append(separator) 

244 continue 

245 

246 setting_name = f'{setting + ":":<20s}' 

247 setting_value = getattr(self, setting) 

248 

249 if isinstance(setting_value, pathlib.Path): 

250 setting_value = os.path.abspath(setting_value) 

251 else: 

252 setting_value = repr(setting_value) 

253 

254 output.append(f'{setting_name} {setting_value}') 

255 

256 output = '\n'.join(output) 

257 return output 

258 

259 # --------------------------------------------------------------------- 

260 

261 # Template class for customizing the Jinja environment 

262 class CustomCodeTemplate: 

263 def __init__( 

264 self, 

265 copy_of_settings, 

266 printer, 

267 ): 

268 # this is only a copy; changing this variable does *not* change the 

269 # settings of StempelWerk 

270 self.settings = copy_of_settings 

271 self.printer = printer 

272 

273 def update_environment( 

274 self, 

275 jinja_environment, 

276 ): 

277 return jinja_environment 

278 

279 def print_error( # pragma: no coverage 

280 self, 

281 message='', 

282 ): 

283 self.printer.error(message) 

284 

285 def print_debug( 

286 self, 

287 message='', 

288 ): 

289 self.printer.debug(message) 

290 

291 # --------------------------------------------------------------------- 

292 

293 class CommandLineParser: 

294 @property 

295 def parser( 

296 self, 

297 ): 

298 class HelpfulArgumentParser(argparse.ArgumentParser): 

299 def exit( 

300 self, 

301 status=0, 

302 message=None, 

303 ): 

304 if status: # pragma: no branch 

305 # display help on errors without showing usage message 

306 # twice 

307 help_message = self.format_help() 

308 help_message = help_message.replace( 

309 self.format_usage(), 

310 '', 

311 ) 

312 print(help_message, file=sys.stderr) 

313 

314 # resume default processing 

315 super().exit(status, message) 

316 

317 parser = HelpfulArgumentParser( 

318 description=StempelWerk.format_description(), 

319 formatter_class=argparse.RawDescriptionHelpFormatter, 

320 ) 

321 

322 parser.add_argument( 

323 '-V', 

324 '--version', 

325 action='version', 

326 version=StempelWerk.APPLICATION_VERSION, 

327 ) 

328 

329 parser.add_argument( 

330 '-m', 

331 '--only-modified', 

332 action='store_true', 

333 help='only process modified templates', 

334 dest='process_only_modified', 

335 ) 

336 

337 parser.add_argument( 

338 '-g', 

339 '--globals', 

340 action='store', 

341 help=( 

342 'string or file containing JSON-formatted global variables' 

343 ), 

344 metavar='JSON', 

345 dest='global_namespace', 

346 ) 

347 

348 verbosity_group = parser.add_mutually_exclusive_group() 

349 

350 verbosity_group.add_argument( 

351 '-qq', 

352 '--ultraquiet', 

353 action='store_const', 

354 const=StempelWerk.VERBOSITY_VERY_LOW, 

355 default=StempelWerk.VERBOSITY_NORMAL, 

356 help='display minimal output', 

357 dest='verbosity', 

358 ) 

359 

360 verbosity_group.add_argument( 

361 '-q', 

362 '--quiet', 

363 action='store_const', 

364 const=StempelWerk.VERBOSITY_LOW, 

365 default=StempelWerk.VERBOSITY_NORMAL, 

366 help='display less output', 

367 dest='verbosity', 

368 ) 

369 

370 verbosity_group.add_argument( 

371 '-v', 

372 '--verbose', 

373 action='store_const', 

374 const=StempelWerk.VERBOSITY_HIGH, 

375 default=StempelWerk.VERBOSITY_NORMAL, 

376 help='display more output and include debug information', 

377 dest='verbosity', 

378 ) 

379 

380 parser.add_argument( 

381 'settings_file_path', 

382 help='path to JSON file containing application settings', 

383 metavar='SETTINGS_FILE', 

384 ) 

385 

386 return parser 

387 

388 def __init__( 

389 self, 

390 command_line_arguments, 

391 ): 

392 cla_without_scriptname = command_line_arguments[1:] 

393 args = self.parser.parse_args(cla_without_scriptname) 

394 

395 self.printer = StempelWerk.LinePrinter(args.verbosity) 

396 

397 # all paths are relative to the root directory, except for the path 

398 # of the settings file, which is relative to the current working 

399 # directory 

400 settings_file_path = StempelWerk.Settings.finalize_path( 

401 '', 

402 args.settings_file_path, 

403 ) 

404 

405 # parse settings file 

406 loaded_settings = self._load_json_file(settings_file_path) 

407 

408 # parse global variables for Jinja environment 

409 # 

410 # provide default global namespace 

411 if args.global_namespace is None: 

412 loaded_settings['global_namespace'] = {} 

413 # parse JSON-formatted dictionary 

414 elif args.global_namespace.strip().startswith('{'): 

415 loaded_settings['global_namespace'] = json.loads( 

416 args.global_namespace 

417 ) 

418 # load JSON file 

419 else: 

420 loaded_settings['global_namespace'] = self._load_json_file( 

421 args.global_namespace 

422 ) 

423 

424 # here's where the magic happens: unpack JSON file into class 

425 self.settings = StempelWerk.Settings(**loaded_settings) 

426 

427 # store settings that may be overwritten at runtime separately 

428 self.process_only_modified = args.process_only_modified 

429 self.verbosity = args.verbosity 

430 

431 def _load_json_file( 

432 self, 

433 json_file_path, 

434 ): 

435 try: 

436 # "json_file_path" may be a string, so convert it to a path 

437 json_file_path = pathlib.Path(json_file_path) 

438 

439 json_string = json_file_path.read_text() 

440 parsed_json = json.loads(json_string) 

441 

442 except FileNotFoundError: 

443 self.printer.error(f'File "{json_file_path}" not found.') 

444 self.printer.error() 

445 exit(1) 

446 

447 except json.decoder.JSONDecodeError as err: 

448 self.printer.error(f'File "{json_file_path}" is broken:') 

449 self.printer.error(f'{err}') 

450 self.printer.error() 

451 exit(1) 

452 

453 except TypeError as err: 

454 self.printer.error( 

455 f'Did you provide all settings in"{json_file_path}"?' 

456 ) 

457 self.printer.error(f'{err}') 

458 self.printer.error() 

459 

460 # print traceback to help with debugging 

461 raise err 

462 

463 return parsed_json 

464 

465 # --------------------------------------------------------------------- 

466 

467 def __init__( 

468 self, 

469 settings, 

470 verbosity=VERBOSITY_NORMAL, 

471 _testing_autocreate_main_directories=False, 

472 ): 

473 self.verbosity = verbosity 

474 self.printer = self.LinePrinter(self.verbosity) 

475 self._display_version(self.verbosity) 

476 

477 self.printer.debug('Loading settings:') 

478 self.printer.debug(' ') 

479 

480 self.settings = settings 

481 

482 for setting in str(self.settings).splitlines(): 

483 self.printer.debug(f' {setting}') 

484 

485 self.printer.debug(' ') 

486 self.printer.debug('Done.') 

487 self.printer.debug() 

488 

489 self.newline_exceptions = { 

490 # ensure Batch files use Windows newlines, otherwise seemingly 

491 # random lines will be executed 

492 '.bat': '\r\n', 

493 '.ps1': '\r\n', 

494 '.sh': '\n', 

495 } 

496 

497 # ease testing 

498 if _testing_autocreate_main_directories: 

499 self.settings.template_dir.mkdir( 

500 parents=True, 

501 exist_ok=True, 

502 ) 

503 self.settings.output_dir.mkdir( 

504 parents=True, 

505 exist_ok=True, 

506 ) 

507 # ease trouble shooting 

508 else: 

509 if not self.settings.template_dir.exists(): 

510 self.printer.error( 

511 f'template directory "{self.settings.template_dir}"' 

512 ) 

513 self.printer.error('does not exist.') 

514 self.printer.error() 

515 exit(1) 

516 

517 if not self.settings.output_dir.exists(): 517 ↛ exitline 517 didn't return from function '__init__' because the condition on line 517 was always true

518 self.printer.error( 

519 f'output directory "{self.settings.output_dir}"' 

520 ) 

521 self.printer.error('does not exist.') 

522 self.printer.error() 

523 exit(1) 

524 

525 def create_environment( 

526 self, 

527 ): 

528 self.printer.debug('Loading templates ...') 

529 

530 # NOTE: Jinja loads templates from sub-directories; 

531 # NOTE: stencils will also be included 

532 # 

533 # cache stencils and templates to improve performance; this loads 

534 # *every* template, and "render_all_templates()" decides which of 

535 # these will be processed 

536 template_loader = jinja2.FileSystemLoader( 

537 self.settings.template_dir, 

538 encoding='utf-8', 

539 ) 

540 

541 self.jinja_environment = jinja2.Environment( 

542 loader=template_loader, 

543 **self.settings.jinja_options, 

544 ) 

545 

546 # load Jinja extensions first so they can be used in custom modules 

547 self._load_jinja_extensions() 

548 self._execute_custom_modules() 

549 

550 template_paths = self._get_templates() 

551 self._check_templates(template_paths) 

552 

553 stencil_paths = self._get_stencils(template_paths) 

554 self._check_stencils(stencil_paths) 

555 

556 self.printer.debug('Done.') 

557 self.printer.debug() 

558 

559 def _get_templates( 

560 self, 

561 ): 

562 template_paths = [] 

563 

564 for template_filename in self.jinja_environment.list_templates(): 

565 template_path = pathlib.Path(template_filename) 

566 template_paths.append(template_path) 

567 

568 return template_paths 

569 

570 def _check_templates( 

571 self, 

572 template_paths, 

573 ): 

574 if not template_paths: 574 ↛ 575line 574 didn't jump to line 575 because the condition on line 574 was never true

575 self.printer.error() 

576 self.printer.error('No templates found.') 

577 self.printer.error() 

578 exit(1) 

579 

580 def _get_stencils( 

581 self, 

582 template_paths, 

583 ): 

584 stencil_paths = [] 

585 

586 for template_path in template_paths: 

587 if self.settings.stencil_dir_name in template_path.parts: 

588 stencil_paths.append(template_path) 

589 

590 return stencil_paths 

591 

592 def _check_stencils( 

593 self, 

594 stencil_paths, 

595 ): 

596 if not self.settings.stencil_dir_name: 

597 return 

598 

599 # check whether stencil directories contain any stencils 

600 if not stencil_paths: 

601 self.printer.error() 

602 self.printer.error('No stencils found.') 

603 self.printer.error() 

604 exit(1) 

605 

606 # list all templates in cache 

607 if ( 

608 self.verbosity > StempelWerk.VERBOSITY_NORMAL 

609 ): # pragma: no coverage 

610 self.printer.debug(' ') 

611 self.printer.debug('Available stencils:') 

612 self.printer.debug(' ') 

613 

614 for stencil_path in stencil_paths: 

615 self.printer.debug(f' - {stencil_path}') 

616 

617 self.printer.debug(' ') 

618 self.printer.debug( 

619 ' Use relative paths to access templates in sub-directories' 

620 ) 

621 self.printer.debug(' (https://stackoverflow.com/a/9644828).') 

622 self.printer.debug(' ') 

623 

624 def _load_jinja_extensions( 

625 self, 

626 ): 

627 if not self.settings.jinja_extensions: 

628 return 

629 

630 self.printer.debug('Loading extensions:') 

631 self.printer.debug(' ') 

632 

633 for extension in self.settings.jinja_extensions: 

634 self.printer.debug(f' - {extension}') 

635 self.jinja_environment.add_extension(extension) 

636 

637 self.printer.debug(' ') 

638 self.printer.debug('Done.') 

639 self.printer.debug() 

640 

641 def _add_stempelwerk_helpers( 

642 self, 

643 ): 

644 # create a new file by inserting a special string into the output; 

645 # this allows you to create multiple files from a single template 

646 def start_new_file(filename): 

647 result = f"""{self.settings.marker_new_file} {filename} 

648{self.settings.marker_content} 

649""" 

650 return result 

651 

652 assert 'start_new_file' not in self.jinja_environment.filters 

653 self.jinja_environment.filters['start_new_file'] = start_new_file 

654 

655 def _execute_custom_modules( 

656 self, 

657 ): 

658 self._add_stempelwerk_helpers() 

659 

660 if not self.settings.custom_modules: 

661 return 

662 

663 self.printer.debug('Appending root directory to module search path:') 

664 self.printer.debug(' ') 

665 

666 # NOTE: "abspath" normalizes the path (seems to be required 

667 # NOTE when loading modules), but may interfere with the 

668 # NOTE interpretation of symlinks 

669 root_dir_module = os.path.abspath(self.settings.root_dir) 

670 

671 self.printer.debug(f' {root_dir_module}') 

672 self.printer.debug(' ') 

673 

674 # allow loading modules from shell client 

675 sys.path.append(root_dir_module) 

676 

677 self.printer.debug('Loading custom modules:') 

678 self.printer.debug(' ') 

679 

680 for module_name in self.settings.custom_modules: 

681 self.printer.debug(f' [ {module_name} ]') 

682 

683 # import code as module 

684 module_spec = importlib.util.find_spec(module_name) 

685 imported_module = importlib.util.module_from_spec(module_spec) 

686 

687 # execute module its own namespace 

688 module_spec.loader.exec_module(imported_module) 

689 

690 # prevent changes to settings 

691 custom_code = imported_module.CustomCode( 

692 copy.deepcopy(self.settings), 

693 self.printer, 

694 ) 

695 

696 self.printer.debug(' - Updating environment ...') 

697 

698 # execute custom code and store updated Jinja environment 

699 self.jinja_environment = custom_code.update_environment( 

700 self.jinja_environment 

701 ) 

702 

703 self.printer.debug(' - Done.') 

704 self.printer.debug(' ') 

705 

706 self.printer.debug('Done.') 

707 self.printer.debug() 

708 

709 def render_template( 

710 self, 

711 template_path, 

712 custom_global_namespace=None, 

713 ): 

714 relative_template_path = template_path.relative_to( 

715 self.settings.template_dir 

716 ) 

717 

718 global_namespace = self._prepare_global_namespace( 

719 custom_global_namespace 

720 ) 

721 

722 # create environment automatically 

723 if not hasattr(self, 'jinja_environment'): 

724 self.create_environment() 

725 

726 raw_content_of_multiple_files = self._render_content( 

727 relative_template_path, 

728 global_namespace, 

729 ) 

730 

731 # "run_results" contains number of processed and saved files 

732 run_results = self._save_content( 

733 raw_content_of_multiple_files, 

734 ) 

735 

736 return run_results 

737 

738 def _prepare_global_namespace( 

739 self, 

740 custom_global_namespace, 

741 ): 

742 # get default global variables 

743 global_namespace = self.settings.global_namespace 

744 

745 # add custom global variables, overwriting existing entries 

746 # 

747 # this allows processing the same template in different ways without 

748 # creating a new instance of StempelWerk 

749 if custom_global_namespace: 749 ↛ 750line 749 didn't jump to line 750 because the condition on line 749 was never true

750 global_namespace.update(custom_global_namespace) 

751 

752 # force users to explicitly mark global variables in code 

753 return {'globals': global_namespace} 

754 

755 def _render_content( 

756 self, 

757 template_path, 

758 global_namespace, 

759 ): 

760 if self.verbosity >= self.VERBOSITY_LOW: # pragma: no branch 

761 print(f'- {template_path}') 

762 

763 # Jinja2 cannot handle Windows paths 

764 template_filename = template_path.as_posix() 

765 

766 try: 

767 jinja_template = self.jinja_environment.get_template( 

768 template_filename, 

769 globals=global_namespace, 

770 ) 

771 

772 # the Jinja2 documentation suggests that applications should use 

773 # environment globals instead of (local) template context 

774 # (https://jinja.palletsprojects.com/en/3.1.x/api/#global-namespace) 

775 content_of_multiple_files = jinja_template.render() 

776 

777 except ( 

778 jinja2.exceptions.TemplateSyntaxError, 

779 jinja2.exceptions.TemplateAssertionError, 

780 ) as err: 

781 self.printer.error() 

782 

783 if self.verbosity < self.VERBOSITY_LOW: # pragma: no coverage 

784 self.printer.error() 

785 self.printer.error(f'in file "{template_filename}"') 

786 

787 self.printer.error(f'{err.message} (line {err.lineno})') 

788 self.printer.error() 

789 

790 # show full backtrace to simplify debugging templates 

791 raise err 

792 

793 except Exception as err: 

794 if self.verbosity < self.VERBOSITY_LOW: # pragma: no coverage 

795 self.printer.error() 

796 self.printer.error() 

797 self.printer.error(f'in file "{template_filename}"') 

798 

799 self.printer.error() 

800 

801 # show full backtrace to simplify debugging templates 

802 raise err 

803 

804 return content_of_multiple_files 

805 

806 def _save_content( 

807 self, 

808 raw_content_of_multiple_files, 

809 ): 

810 # split content into multiple files 

811 split_contents = raw_content_of_multiple_files.split( 

812 self.settings.marker_new_file 

813 ) 

814 

815 processed_templates = 1 

816 saved_files = 0 

817 

818 for raw_content_of_single_file in split_contents: 

819 # content starts with "marker_new_file", so first string is empty 

820 # (or contains whitespace when a template is not well written) 

821 if not raw_content_of_single_file.strip(): 

822 continue 

823 

824 saved_files += self._save_single_file(raw_content_of_single_file) 

825 

826 if self.verbosity >= self.VERBOSITY_NORMAL: # pragma: no branch 

827 print() 

828 

829 return { 

830 'processed_templates': processed_templates, 

831 'saved_files': saved_files, 

832 } 

833 

834 def _save_single_file( 

835 self, 

836 raw_content, 

837 ): 

838 output_file_name, processed_content = self._process_raw_content( 

839 raw_content 

840 ) 

841 

842 if self.verbosity >= self.VERBOSITY_NORMAL: # pragma: no branch 

843 print(f' - {output_file_name}') 

844 

845 output_file_path = self.Settings.finalize_path( 

846 self.settings.output_dir, 

847 output_file_name, 

848 ) 

849 

850 self._create_output_directory( 

851 output_file_path, 

852 ) 

853 

854 # use default newline character unless there is an exception (such as 

855 # for Windows batch files) 

856 newline = self.newline_exceptions.get( 

857 output_file_path.suffix, 

858 self.settings.newline, 

859 ) 

860 

861 # Jinja2 encodes all strings in UTF-8 

862 output_file_path.write_text( 

863 processed_content, 

864 encoding='utf-8', 

865 newline=newline, 

866 ) 

867 

868 return 1 

869 

870 def _process_raw_content( 

871 self, 

872 raw_content, 

873 ): 

874 new_file_markers = raw_content.count( 

875 self.settings.marker_new_file, 

876 ) 

877 content_markers = raw_content.count( 

878 self.settings.marker_content, 

879 ) 

880 

881 # catch problems with file separation markers early 

882 if new_file_markers != 0 or content_markers != 1: 

883 self.printer.error( 

884 'there was a problem with splitting the output into files,' 

885 ) 

886 self.printer.error( 

887 'check "marker_new_file", "marker_content" and your templates.' 

888 ) 

889 self.printer.error() 

890 exit(1) 

891 

892 # extract name and content of output file 

893 output_file_name, processed_content = raw_content.split( 

894 self.settings.marker_content, 

895 1, 

896 ) 

897 

898 output_file_name = output_file_name.strip() 

899 processed_content = processed_content.lstrip() 

900 

901 return output_file_name, processed_content 

902 

903 def _create_output_directory( 

904 self, 

905 output_file_path, 

906 ): 

907 output_directory = output_file_path.parent 

908 

909 if output_directory.is_dir(): 

910 return 

911 

912 if self.settings.create_directories: 

913 output_directory.mkdir(parents=True) 

914 

915 if self.verbosity >= self.VERBOSITY_NORMAL: # pragma: no branch 

916 print(f' - created directory "{output_directory}"') 

917 else: 

918 self.printer.error(f'directory "{output_directory}"') 

919 self.printer.error('does not exist.') 

920 self.printer.error() 

921 exit(1) 

922 

923 def render_all_templates( 

924 self, 

925 process_only_modified=False, 

926 custom_global_namespace=None, 

927 ): 

928 start_of_processing = datetime.datetime.now() 

929 

930 template_filenames = self._find_templates(process_only_modified) 

931 

932 processed_templates = 0 

933 saved_files = 0 

934 

935 for template_filename in template_filenames: 

936 # "run_results" contains number of processed and saved files 

937 run_results = self.render_template( 

938 template_filename, 

939 custom_global_namespace, 

940 ) 

941 

942 processed_templates += run_results['processed_templates'] 

943 saved_files += run_results['saved_files'] 

944 

945 if self.verbosity < self.VERBOSITY_LOW: # pragma: no coverage 

946 self._show_progress( 

947 processed_templates, 

948 is_finished=False, 

949 ) 

950 

951 # only save time of current run and show statistics when files have 

952 # actually been processed 

953 if template_filenames: 

954 self._store_last_run(start_of_processing) 

955 self._display_statistics( 

956 start_of_processing, 

957 processed_templates, 

958 saved_files, 

959 ) 

960 

961 return { 

962 'processed_templates': processed_templates, 

963 'saved_files': saved_files, 

964 } 

965 

966 def _show_progress( # pragma: no coverage 

967 self, 

968 processed_templates, 

969 is_finished, 

970 ): 

971 if is_finished: 

972 # finish last line 

973 remaining_dots = processed_templates % 10 

974 print('.' * remaining_dots, end='') 

975 

976 if (processed_templates % 40) != 0: 

977 print() 

978 elif (processed_templates % 40) == 0: 

979 print('..........', end='\n') 

980 elif (processed_templates % 10) == 0: 

981 print('..........', end=' ') 

982 

983 def _get_last_run( 

984 self, 

985 ) -> int | None: 

986 try: 

987 last_run_timestamp = self.settings.last_run_file.read_text() 

988 last_run_timestamp = last_run_timestamp.strip() 

989 

990 return int(last_run_timestamp) 

991 except OSError: 

992 return None 

993 

994 def _store_last_run( 

995 self, 

996 last_run, 

997 ): 

998 # convert datetime to UNIX time 

999 last_run_timestamp = last_run.timestamp() 

1000 

1001 # take care of file systems with inaccurate timestamps (Microsoft, 

1002 # I mean you!) and other edge cases 

1003 last_run_timestamp = math.floor(last_run_timestamp) - 2 

1004 

1005 self.settings.last_run_file.write_text( 

1006 str(last_run_timestamp), 

1007 ) 

1008 

1009 def _display_statistics( 

1010 self, 

1011 start_of_processing, 

1012 processed_templates, 

1013 saved_files, 

1014 ): 

1015 processing_time = datetime.datetime.now() - start_of_processing 

1016 

1017 time_per_template = processing_time / processed_templates 

1018 time_per_file = processing_time / saved_files 

1019 

1020 self.printer.debug(f'Time per template file: {time_per_template}') 

1021 self.printer.debug(f'Time per output file: {time_per_file}') 

1022 self.printer.debug() 

1023 

1024 if self.verbosity < self.VERBOSITY_LOW: # pragma: no coverage 

1025 # finish last line 

1026 self._show_progress(processed_templates, is_finished=True) 

1027 

1028 if self.verbosity < self.VERBOSITY_NORMAL: # pragma: no coverage 

1029 print() 

1030 print( 

1031 f'{processed_templates} =>', 

1032 f'{saved_files} in {processing_time}', 

1033 ) 

1034 print() 

1035 else: 

1036 print( 

1037 f'TOTAL: {processed_templates} templates =>', 

1038 f'{saved_files} files in {processing_time}', 

1039 ) 

1040 print() 

1041 

1042 def _find_templates( 

1043 self, 

1044 process_only_modified, 

1045 ): 

1046 herkules_selector = { 

1047 # do not render stencils 

1048 'excluded_directory_names': [ 

1049 self.settings.stencil_dir_name, 

1050 ], 

1051 'excluded_file_names': [], 

1052 'included_file_names': self.settings.included_file_names, 

1053 } 

1054 

1055 modified_since = None 

1056 if process_only_modified: 

1057 # get time of last run 

1058 modified_since = self._get_last_run() 

1059 

1060 # find matching files in template directory 

1061 template_filenames = herkules( 

1062 self.settings.template_dir, 

1063 selector=herkules_selector, 

1064 modified_since=modified_since, 

1065 relative_to_root=False, 

1066 ) 

1067 

1068 return template_filenames 

1069 

1070 

1071def main_cli(): # pragma: no coverage 

1072 command_line_arguments = sys.argv 

1073 parsed_args = StempelWerk.CommandLineParser(command_line_arguments) 

1074 

1075 sw = StempelWerk(parsed_args.settings, parsed_args.verbosity) 

1076 

1077 # if you want to modify the global namespace programmatically, here is the 

1078 # right place to do so; this will extend / overwrite the global variables 

1079 # specified on the command line 

1080 custom_global_namespace = {} 

1081 

1082 sw.render_all_templates( 

1083 parsed_args.process_only_modified, 

1084 custom_global_namespace, 

1085 ) 

1086 

1087 

1088if __name__ == '__main__': # pragma: no coverage 

1089 main_cli()