diff --git a/CHANGELOG.md b/CHANGELOG.md index ec3069f..2020c2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.7.0] - 2024-05-30 + +## Added + +- A new `DirectoryRenderer` and `DirectoryTemplate` to render entire directories + of templates in one go +- Support for `{% skip_if %}` and `{% setfilename %}` tags to control directory + names and skipping by adding them to a file called `__stencil_meta__` placed in + any directory in a `DirectoryTemplate` +- The ability to run directory rendering via command line with the `-d` flag + ## [0.6.0] - 2024-05-30 diff --git a/_sandbox_input.yaml b/_sandbox_input.yaml index cf01685..72517a6 100644 --- a/_sandbox_input.yaml +++ b/_sandbox_input.yaml @@ -2,4 +2,8 @@ name: Bob age: 7 colors: favorite: Blue - weakness: Yellow \ No newline at end of file + weakness: Yellow +foo: + skip_module: false + module: + name: yomoma \ No newline at end of file diff --git a/ccpstencil/__init__.py b/ccpstencil/__init__.py index 3bd62f4..db1f83d 100644 --- a/ccpstencil/__init__.py +++ b/ccpstencil/__init__.py @@ -1,4 +1,4 @@ -__version__ = '0.6.1' +__version__ = '0.7.0' __author__ = 'Thordur Matthiasson ' __license__ = 'MIT License' diff --git a/ccpstencil/cli/ccp_stencil/_runner.py b/ccpstencil/cli/ccp_stencil/_runner.py index 867614d..fdd8756 100644 --- a/ccpstencil/cli/ccp_stencil/_runner.py +++ b/ccpstencil/cli/ccp_stencil/_runner.py @@ -3,7 +3,7 @@ ] import os import sys -from ccptools.structs import * +from ccpstencil.structs import * from ccpstencil.stencils import * import logging log = logging.getLogger(__file__) @@ -15,12 +15,14 @@ def __init__(self): self.template: Optional[str] = None self.string_template: Optional[str] = None + self.directory_template: Optional[str] = None self.input: Optional[str] = None self.additional_arg_list: List[str] = [] self.output: Optional[str] = None self.no_overwrite: bool = False + self.no_purge: bool = False def _make_cwd_importable(self): cwd = os.getcwd() @@ -36,10 +38,16 @@ def run(self): rnd.context = self.get_context() res = rnd.render() if self.output: - print(f'Wrote file: {res}') + if isinstance(res, List): + for f in res: + print(f'Wrote file: {f}') + else: + print(f'Wrote file: {res}') def get_template(self) -> ITemplate: - if self.template: + if self.directory_template: + return DirectoryTemplate(self.directory_template) + elif self.template: return FileTemplate(self.template) else: return StringTemplate(self.string_template) @@ -60,7 +68,13 @@ def get_context(self) -> IContext: return ctx def get_renderer(self) -> IRenderer: - if self.output: + if self.directory_template: + if not self.output: + raise RenderError('Must specify output directory for directory rendering') + return DirectoryRenderer(output_path=self.output, + overwrite=not self.no_overwrite, + purge=not self.no_purge) + elif self.output: return FileRenderer(self.output, overwrite=not self.no_overwrite) else: diff --git a/ccpstencil/cli/ccp_stencil/main.py b/ccpstencil/cli/ccp_stencil/main.py index 9e4aea2..a1eb632 100644 --- a/ccpstencil/cli/ccp_stencil/main.py +++ b/ccpstencil/cli/ccp_stencil/main.py @@ -21,12 +21,15 @@ def main(): ' If this is a path (ends with /), the name of the rendered file will be the same as the input template.', default='', nargs='?') parser.add_argument('--no-overwrite', action="store_true", help='Makes sore existing output files are not overwritten') + parser.add_argument('--no-purge', action="store_true", help='Skips purging the output path when rendering directories') input_group = parser.add_mutually_exclusive_group(required=True) input_group.add_argument('-t', '--template', default='', help='Template file to render') input_group.add_argument('-s', '--string-template', default='', help='Supply a string directly from the command line to use as a template instead of a file') + input_group.add_argument('-d', '--directory-template', default='', + help='Root directory of files and directories to render. This requires the output argument to be a directory') args = parser.parse_args() @@ -43,8 +46,10 @@ def main(): runner.input = args.input or None runner.output = args.output or None runner.no_overwrite = args.no_overwrite or False + runner.no_purge = args.no_purge or False runner.template = args.template or None runner.string_template = args.string_template or None + runner.directory_template = args.directory_template or None if args.arg: for arg in args.arg: runner.additional_arg_list.append(arg) diff --git a/ccpstencil/context/_alviss.py b/ccpstencil/context/_alviss.py index 8fdc328..f2e17b7 100644 --- a/ccpstencil/context/_alviss.py +++ b/ccpstencil/context/_alviss.py @@ -28,5 +28,4 @@ def nested_update(self, key_tuple: Union[str, Tuple[str]], value: Any): self._data.update(**iters.nest_dict(list(key_tuple), value)) # noqa def as_dict(self) -> Dict: - log.debug(f'as_dict() YAML dump:\n{self._data.as_yaml(unmaksed=True)}') return self._data.as_dict(unmaksed=True) diff --git a/ccpstencil/renderer/__init__.py b/ccpstencil/renderer/__init__.py index f023a0b..1cb2a84 100644 --- a/ccpstencil/renderer/__init__.py +++ b/ccpstencil/renderer/__init__.py @@ -3,3 +3,4 @@ from ._string import * from ._stdout import * from ._file import * +from ._dir import * diff --git a/ccpstencil/renderer/_base.py b/ccpstencil/renderer/_base.py index 814aaf7..3acb844 100644 --- a/ccpstencil/renderer/_base.py +++ b/ccpstencil/renderer/_base.py @@ -132,7 +132,7 @@ def render(self): rendered_string = self._render_as_string() return self._output_rendered_results(rendered_string) except CancelRendering: - log.info(f'Rendering cancelled by skip_if tag') + log.debug(f'Rendering cancelled by skip_if tag') return None @abc.abstractmethod diff --git a/ccpstencil/renderer/_dir.py b/ccpstencil/renderer/_dir.py index e69de29..37b0a65 100644 --- a/ccpstencil/renderer/_dir.py +++ b/ccpstencil/renderer/_dir.py @@ -0,0 +1,119 @@ +__all__ = [ + 'DirectoryRenderer', +] + +from ccpstencil.structs import * +from pathlib import Path + +from . import FileRenderer +from ._base import * +from ccpstencil.template import DirectoryTemplate +import shutil +import logging + +from ..template import FileTemplate + +log = logging.getLogger(__file__) + + +class DirectoryRenderer(_BaseRenderer): + _VALID_TEMPLATES = (DirectoryTemplate,) + + def __init__(self, output_path: T_PATH, + context: Optional[IContext] = None, + template: Optional[DirectoryTemplate] = None, + overwrite: bool = True, + purge: bool = True, + **kwargs): + self._overwrite = overwrite + self._purge = purge + self._output_path = output_path + self._rendered: List[str] = [] + if isinstance(self._output_path, str): + self._output_path = Path(self._output_path) + super().__init__(context, template, **kwargs) + + def _ensure_root(self): + self._do_purge() + if not self._output_path.exists(): + log.debug(f'Creating target output root path: {self._output_path}...') + self._output_path.mkdir(parents=True, exist_ok=True) + + def _do_purge(self): + if self._purge: + if self._output_path.exists(): + if not self._overwrite: + raise OutputFileExistsError(f'Purge is enabled but the target output path already exists and overwriting is disabled: {self._output_path}') + log.debug(f'Purging output path: {self._output_path}...') + shutil.rmtree(self._output_path.absolute(), ignore_errors=True) + + def _make_dir(self, dir_template: DirectoryTemplate) -> bool: + full_path = self._output_path / dir_template.target_path + if dir_template.skip_me: + log.debug(f'Skipping due to skip_if tag: {full_path}...') + return False + else: + log.debug(f'Creating relative directory: {full_path}...') + full_path.mkdir(exist_ok=True) + return True + + def _build_tree(self): + def _build_tree_inner(dir_template: DirectoryTemplate): + for dt in dir_template.directories: + if self._make_dir(dt): + _build_tree_inner(dt) + _build_tree_inner(self.template) # noqa + + def _render_file(self, template: FileTemplate, target_path: Optional[Path] = None): + log.debug(f'Rendering {template}...') + if target_path: + p = self._output_path / target_path / template.file_name + else: + p = self._output_path / template.file_name + r = FileRenderer(output_path=p, + context=self.context, + template=template, + overwrite=self._overwrite) + res = r.render() + if target_path: + rp = target_path / template.file_name + else: + rp = template.file_name + if res is None: + log.debug(f'Skipped rendering {rp} due to skip_if tag...') + else: + log.debug(f'Rendered: {rp}') + self._rendered.append(str(p)) + + def _build_files(self): + def _build_files_inner(dir_template: DirectoryTemplate): + if dir_template.skip_me: + return + for f in dir_template.files: + self._render_file(f, None if dir_template.is_root else dir_template.target_path) + for dt in dir_template.directories: + _build_files_inner(dt) + + _build_files_inner(self.template) # noqa + + def render(self) -> List[str]: + self._pre_flight() + self._ensure_root() + self._build_tree() + self._build_files() + return self._rendered + + def _output_rendered_results(self, rendered_string: str) -> str: + pass + + def _render_as_string(self) -> str: + pass + + @property + def output_file_name(self) -> Optional[str]: + pass + + @output_file_name.setter + def output_file_name(self, value: str): + pass + diff --git a/ccpstencil/template/__init__.py b/ccpstencil/template/__init__.py index a9a171d..70ee5d3 100644 --- a/ccpstencil/template/__init__.py +++ b/ccpstencil/template/__init__.py @@ -2,3 +2,4 @@ from ._base import * from ._string import * from ._file import * +from ._dir import * diff --git a/ccpstencil/template/_dir.py b/ccpstencil/template/_dir.py new file mode 100644 index 0000000..4327338 --- /dev/null +++ b/ccpstencil/template/_dir.py @@ -0,0 +1,153 @@ +__all__ = [ + 'DirectoryTemplate', +] + +import logging + +from ccpstencil.structs import * +from pathlib import Path + +from ._base import * +from ._file import * + +import logging +log = logging.getLogger(__file__) + + +class DirectoryTemplate(_BaseTemplate): + def __init__(self, dir_path: T_PATH, parent: Optional['DirectoryTemplate'] = None, **kwargs): + super().__init__(**kwargs) + if isinstance(dir_path, str): + dir_path = Path(dir_path) + self._dir_path: Path = dir_path + + if not self._dir_path.exists(): + raise TemplateNotFoundError(f'Template root does not exist: {self._dir_path}') + + if not self._dir_path.is_dir(): + raise TemplateNotFoundError(f'Template root is not a directory: {self._dir_path}') + + self._directories: List[DirectoryTemplate] = [] + self._files: List[FileTemplate] = [] + self._meta_file: Optional[Path] = None + + self._parent = parent + + self._target_name: Optional[str] = None + self._skip_me: bool = False + self._is_rendered: bool = False + + self._crawl() + + @property + def is_root(self) -> bool: + return self._parent is None + + @property + def absolute_source_path(self) -> Path: + """This is the full absolute path to the source template directory!""" + if self.is_root: + return self._dir_path.absolute() + return self._parent.absolute_source_path / self.source_name + + @property + def source_path(self) -> Path: + """This is the relative path to the source template directory!""" + if self.is_root: + return Path('.') + return self._parent.source_path / self.source_name + + @property + def source_name(self) -> str: + """This is name of the source template directory!""" + return self._dir_path.name + + @property + def target_path(self) -> Path: + """This is the relative path to the rendered target path""" + if self.is_root: + return Path('.') + return self._parent.target_path / self.target_name + + @property + def target_name(self) -> str: + """This is name of the target directory to render (can be different from source name via __stencil_meta__)""" + self._render_target_name() + return self._target_name + + @property + def skip_me(self) -> bool: + self._render_target_name() + return self._skip_me + + def _render_target_name(self): + if not self._is_rendered: + self._is_rendered = True + self._skip_me = False + if self.meta_template: + from ccpstencil.renderer import StringRenderer + r = StringRenderer(context=self.renderer.context, + template=self.meta_template) + result = r.render() + if r.output_file_name != '__stencil_meta__': + self._target_name = r.output_file_name + else: + self._target_name = self.source_name + + if result is None: + self._skip_me = True + else: + self._target_name = self.source_name + + def set_renderer(self, renderer: IRenderer): + super().set_renderer(renderer) + for d in self._directories: + d.set_renderer(renderer) + + @property + def meta_template(self) -> Optional[FileTemplate]: + if self._meta_file: + return FileTemplate(file_path=self._meta_file.absolute()) + return None + + @property + def directories(self) -> List['DirectoryTemplate']: + return self._directories + + @property + def files(self) -> List['FileTemplate']: + return self._files + + def _crawl(self): + for i in self._dir_path.iterdir(): + if i.is_dir(): + self._directories.append(DirectoryTemplate(i, parent=self)) + else: + if i.name == '__stencil_meta__': + self._meta_file = i + if self._parent is None: + raise TemplateError('The __stencil_meta__ file is not allowed in the template root directory') + else: + log.debug(f'Adding {self.absolute_source_path} / {i.name}') + self._files.append(FileTemplate(self.absolute_source_path / i.name)) + + def dump_lines(self) -> List[str]: + s = [] + if self._parent is None: + s.append('I AM ROOT:') + + for d in self._directories: + s.append(f'+- {d.source_name}/ [{d.target_path}]') + for x in d.dump_lines(): + s.append(f'| {x}') + for f in self._files: + s.append(f'+- {f.file_name} [{f.get_file_path()}]') + return s + + def dump(self) -> str: + return '\n'.join(self.dump_lines()) + + def get_jinja_template(self) -> jinja2.Template: + pass + + diff --git a/ccpstencil/template/_file.py b/ccpstencil/template/_file.py index 9663b0e..3cf5cf4 100644 --- a/ccpstencil/template/_file.py +++ b/ccpstencil/template/_file.py @@ -13,6 +13,10 @@ def __init__(self, file_path: T_PATH, **kwargs): super().__init__(**kwargs) self._file_path: T_PATH = file_path + @property + def file_name(self) -> str: + return str(self.get_file_path().name) + def _read_file(self) -> str: as_path = self.get_file_path()