Coverage for stempelwerk / StempelWerk.py: 96%
370 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-03 21:20 +0100
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-03 21:20 +0100
1#! /usr/bin/env python3
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# ----------------------------------------------------------------------------
44import argparse
45import copy
46import dataclasses
47import datetime
48import importlib
49import json
50import math
51import os
52import pathlib
53import sys
55import jinja2
56from herkules.Herkules import herkules
58__version__ = '1.1.1'
61class StempelWerk:
62 APPLICATION = 'StempelWerk'
63 AUTHOR = 'Martin Zuther'
64 DESCRIPTION = 'Automatic code generation from Jinja2 templates.'
65 LICENSE = 'BSD 3-Clause License'
67 APPLICATION_VERSION = f'{APPLICATION} v{__version__}'
68 COPYRIGHT = f'{APPLICATION_VERSION:20} (c) 2020-2026 {AUTHOR}'
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
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 )
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 )
99 def _display_version(
100 self,
101 verbosity=VERBOSITY_NORMAL,
102 ):
103 version_message = self.format_version(verbosity)
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()
113 # ---------------------------------------------------------------------
115 class LinePrinter:
116 def __init__(
117 self,
118 verbosity,
119 ):
120 self.verbosity = verbosity
122 def _print_context(
123 self,
124 context,
125 message,
126 ):
127 if message:
128 message = f'{context}: {message}'
129 print(message)
131 def error(
132 self,
133 message='',
134 ):
135 self._print_context('ERROR', message)
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)
146 # ---------------------------------------------------------------------
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
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()
184 new_path = root_dir / original_path
186 return new_path.expanduser()
188 def __post_init__(
189 self,
190 ):
191 # root directory is relative to the current directory
192 current_dir = pathlib.Path.cwd()
194 self.root_dir = self.finalize_path(
195 current_dir,
196 self.root_dir,
197 )
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 )
205 self.output_dir = self.finalize_path(
206 self.root_dir,
207 self.output_dir,
208 )
210 self.last_run_file = self.finalize_path(
211 self.root_dir,
212 self.last_run_file,
213 )
215 def __str__(
216 self,
217 ):
218 output = []
219 separator = ' '
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 ]
241 for setting in settings:
242 if setting == separator:
243 output.append(separator)
244 continue
246 setting_name = f'{setting + ":":<20s}'
247 setting_value = getattr(self, setting)
249 if isinstance(setting_value, pathlib.Path):
250 setting_value = os.path.abspath(setting_value)
251 else:
252 setting_value = repr(setting_value)
254 output.append(f'{setting_name} {setting_value}')
256 output = '\n'.join(output)
257 return output
259 # ---------------------------------------------------------------------
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
273 def update_environment(
274 self,
275 jinja_environment,
276 ):
277 return jinja_environment
279 def print_error( # pragma: no coverage
280 self,
281 message='',
282 ):
283 self.printer.error(message)
285 def print_debug(
286 self,
287 message='',
288 ):
289 self.printer.debug(message)
291 # ---------------------------------------------------------------------
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)
314 # resume default processing
315 super().exit(status, message)
317 parser = HelpfulArgumentParser(
318 description=StempelWerk.format_description(),
319 formatter_class=argparse.RawDescriptionHelpFormatter,
320 )
322 parser.add_argument(
323 '-V',
324 '--version',
325 action='version',
326 version=StempelWerk.APPLICATION_VERSION,
327 )
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 )
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 )
348 verbosity_group = parser.add_mutually_exclusive_group()
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 )
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 )
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 )
380 parser.add_argument(
381 'settings_file_path',
382 help='path to JSON file containing application settings',
383 metavar='SETTINGS_FILE',
384 )
386 return parser
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)
395 self.printer = StempelWerk.LinePrinter(args.verbosity)
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 )
405 # parse settings file
406 loaded_settings = self._load_json_file(settings_file_path)
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 )
424 # here's where the magic happens: unpack JSON file into class
425 self.settings = StempelWerk.Settings(**loaded_settings)
427 # store settings that may be overwritten at runtime separately
428 self.process_only_modified = args.process_only_modified
429 self.verbosity = args.verbosity
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)
439 json_string = json_file_path.read_text()
440 parsed_json = json.loads(json_string)
442 except FileNotFoundError:
443 self.printer.error(f'File "{json_file_path}" not found.')
444 self.printer.error()
445 exit(1)
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)
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()
460 # print traceback to help with debugging
461 raise err
463 return parsed_json
465 # ---------------------------------------------------------------------
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)
477 self.printer.debug('Loading settings:')
478 self.printer.debug(' ')
480 self.settings = settings
482 for setting in str(self.settings).splitlines():
483 self.printer.debug(f' {setting}')
485 self.printer.debug(' ')
486 self.printer.debug('Done.')
487 self.printer.debug()
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 }
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)
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)
525 def create_environment(
526 self,
527 ):
528 self.printer.debug('Loading templates ...')
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 )
541 self.jinja_environment = jinja2.Environment(
542 loader=template_loader,
543 **self.settings.jinja_options,
544 )
546 # load Jinja extensions first so they can be used in custom modules
547 self._load_jinja_extensions()
548 self._execute_custom_modules()
550 template_paths = self._get_templates()
551 self._check_templates(template_paths)
553 stencil_paths = self._get_stencils(template_paths)
554 self._check_stencils(stencil_paths)
556 self.printer.debug('Done.')
557 self.printer.debug()
559 def _get_templates(
560 self,
561 ):
562 template_paths = []
564 for template_filename in self.jinja_environment.list_templates():
565 template_path = pathlib.Path(template_filename)
566 template_paths.append(template_path)
568 return template_paths
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)
580 def _get_stencils(
581 self,
582 template_paths,
583 ):
584 stencil_paths = []
586 for template_path in template_paths:
587 if self.settings.stencil_dir_name in template_path.parts:
588 stencil_paths.append(template_path)
590 return stencil_paths
592 def _check_stencils(
593 self,
594 stencil_paths,
595 ):
596 if not self.settings.stencil_dir_name:
597 return
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)
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(' ')
614 for stencil_path in stencil_paths:
615 self.printer.debug(f' - {stencil_path}')
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(' ')
624 def _load_jinja_extensions(
625 self,
626 ):
627 if not self.settings.jinja_extensions:
628 return
630 self.printer.debug('Loading extensions:')
631 self.printer.debug(' ')
633 for extension in self.settings.jinja_extensions:
634 self.printer.debug(f' - {extension}')
635 self.jinja_environment.add_extension(extension)
637 self.printer.debug(' ')
638 self.printer.debug('Done.')
639 self.printer.debug()
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
652 assert 'start_new_file' not in self.jinja_environment.filters
653 self.jinja_environment.filters['start_new_file'] = start_new_file
655 def _execute_custom_modules(
656 self,
657 ):
658 self._add_stempelwerk_helpers()
660 if not self.settings.custom_modules:
661 return
663 self.printer.debug('Appending root directory to module search path:')
664 self.printer.debug(' ')
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)
671 self.printer.debug(f' {root_dir_module}')
672 self.printer.debug(' ')
674 # allow loading modules from shell client
675 sys.path.append(root_dir_module)
677 self.printer.debug('Loading custom modules:')
678 self.printer.debug(' ')
680 for module_name in self.settings.custom_modules:
681 self.printer.debug(f' [ {module_name} ]')
683 # import code as module
684 module_spec = importlib.util.find_spec(module_name)
685 imported_module = importlib.util.module_from_spec(module_spec)
687 # execute module its own namespace
688 module_spec.loader.exec_module(imported_module)
690 # prevent changes to settings
691 custom_code = imported_module.CustomCode(
692 copy.deepcopy(self.settings),
693 self.printer,
694 )
696 self.printer.debug(' - Updating environment ...')
698 # execute custom code and store updated Jinja environment
699 self.jinja_environment = custom_code.update_environment(
700 self.jinja_environment
701 )
703 self.printer.debug(' - Done.')
704 self.printer.debug(' ')
706 self.printer.debug('Done.')
707 self.printer.debug()
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 )
718 global_namespace = self._prepare_global_namespace(
719 custom_global_namespace
720 )
722 # create environment automatically
723 if not hasattr(self, 'jinja_environment'):
724 self.create_environment()
726 raw_content_of_multiple_files = self._render_content(
727 relative_template_path,
728 global_namespace,
729 )
731 # "run_results" contains number of processed and saved files
732 run_results = self._save_content(
733 raw_content_of_multiple_files,
734 )
736 return run_results
738 def _prepare_global_namespace(
739 self,
740 custom_global_namespace,
741 ):
742 # get default global variables
743 global_namespace = self.settings.global_namespace
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)
752 # force users to explicitly mark global variables in code
753 return {'globals': global_namespace}
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}')
763 # Jinja2 cannot handle Windows paths
764 template_filename = template_path.as_posix()
766 try:
767 jinja_template = self.jinja_environment.get_template(
768 template_filename,
769 globals=global_namespace,
770 )
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()
777 except (
778 jinja2.exceptions.TemplateSyntaxError,
779 jinja2.exceptions.TemplateAssertionError,
780 ) as err:
781 self.printer.error()
783 if self.verbosity < self.VERBOSITY_LOW: # pragma: no coverage
784 self.printer.error()
785 self.printer.error(f'in file "{template_filename}"')
787 self.printer.error(f'{err.message} (line {err.lineno})')
788 self.printer.error()
790 # show full backtrace to simplify debugging templates
791 raise err
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}"')
799 self.printer.error()
801 # show full backtrace to simplify debugging templates
802 raise err
804 return content_of_multiple_files
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 )
815 processed_templates = 1
816 saved_files = 0
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
824 saved_files += self._save_single_file(raw_content_of_single_file)
826 if self.verbosity >= self.VERBOSITY_NORMAL: # pragma: no branch
827 print()
829 return {
830 'processed_templates': processed_templates,
831 'saved_files': saved_files,
832 }
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 )
842 if self.verbosity >= self.VERBOSITY_NORMAL: # pragma: no branch
843 print(f' - {output_file_name}')
845 output_file_path = self.Settings.finalize_path(
846 self.settings.output_dir,
847 output_file_name,
848 )
850 self._create_output_directory(
851 output_file_path,
852 )
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 )
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 )
868 return 1
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 )
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)
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 )
898 output_file_name = output_file_name.strip()
899 processed_content = processed_content.lstrip()
901 return output_file_name, processed_content
903 def _create_output_directory(
904 self,
905 output_file_path,
906 ):
907 output_directory = output_file_path.parent
909 if output_directory.is_dir():
910 return
912 if self.settings.create_directories:
913 output_directory.mkdir(parents=True)
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)
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()
930 template_filenames = self._find_templates(process_only_modified)
932 processed_templates = 0
933 saved_files = 0
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 )
942 processed_templates += run_results['processed_templates']
943 saved_files += run_results['saved_files']
945 if self.verbosity < self.VERBOSITY_LOW: # pragma: no coverage
946 self._show_progress(
947 processed_templates,
948 is_finished=False,
949 )
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 )
961 return {
962 'processed_templates': processed_templates,
963 'saved_files': saved_files,
964 }
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='')
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=' ')
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()
990 return int(last_run_timestamp)
991 except OSError:
992 return None
994 def _store_last_run(
995 self,
996 last_run,
997 ):
998 # convert datetime to UNIX time
999 last_run_timestamp = last_run.timestamp()
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
1005 self.settings.last_run_file.write_text(
1006 str(last_run_timestamp),
1007 )
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
1017 time_per_template = processing_time / processed_templates
1018 time_per_file = processing_time / saved_files
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()
1024 if self.verbosity < self.VERBOSITY_LOW: # pragma: no coverage
1025 # finish last line
1026 self._show_progress(processed_templates, is_finished=True)
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()
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 }
1055 modified_since = None
1056 if process_only_modified:
1057 # get time of last run
1058 modified_since = self._get_last_run()
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 )
1068 return template_filenames
1071def main_cli(): # pragma: no coverage
1072 command_line_arguments = sys.argv
1073 parsed_args = StempelWerk.CommandLineParser(command_line_arguments)
1075 sw = StempelWerk(parsed_args.settings, parsed_args.verbosity)
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 = {}
1082 sw.render_all_templates(
1083 parsed_args.process_only_modified,
1084 custom_global_namespace,
1085 )
1088if __name__ == '__main__': # pragma: no coverage
1089 main_cli()