diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml new file mode 100644 index 0000000..b545a44 --- /dev/null +++ b/.github/workflows/publish-to-pypi.yml @@ -0,0 +1,29 @@ +name: Publish Python Package + +on: + release: + types: [ created ] + +jobs: + build-and-publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.8' + - name: Install Dependencies + run: | + python --version + python -m pip install --upgrade pip + pip install --upgrade setuptools wheel twine + - name: Build and Package + run: | + python setup.py sdist bdist_wheel + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@v1.4.2 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} + verbose: true \ No newline at end of file diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml new file mode 100644 index 0000000..333debf --- /dev/null +++ b/.github/workflows/unit-test.yml @@ -0,0 +1,28 @@ +name: Run Unit Tests + +on: [push, pull_request] + +jobs: + unit-tests: + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run tests + run: | + python -m unittest discover -v -f ./tests diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2522d83 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Virtual environments +venv*/ +.venv*/ + +# IDEA Stuff +.idea/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e528265 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +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.1.0] - UNRELEASED + +Initial release + +### Added + +- Everything! diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e07ad05 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 CCP Games + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index d11d33d..169b623 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,84 @@ -# ccp-stencil -An Alviss powered Template renderer where the context input can be files and entire directory structures can be rendered. +# CCP Stencil + +An Alviss and Jinja2 powered template renderer where the context input can be +files and entire directory structures can be rendered. + +This is a generalized variant of the "CCP Borg Bootstrapper" project +bootstrapping and build and deployment tool which used entire "template" +projects that were rendered to bootstrap entire projects and to render CI/CD +manifests on demand. + +The rest of this readme is (at the moment) just a "sketch" for how this package +should work and was written before any actual code or functionality. + +## Context + +- [x] No context (Weird use case...?!?) +- [x] kwargs context (via code) +- [x] Dict context (via code) +- [x] Alviss file context (json/yaml + inheritance) +- [ ] Args context (from commandline) + +## Template + +- [x] String template (via code) +- [x] File template +- [ ] Args template (from commandline) +- [ ] Directory template + +## Renderer + +- [x] String renderer (via code) +- [x] Stdout renderer (for commandline) +- [x] File renderer +- [ ] Directory renderer + +## Other features...? + +- ENV var rendering (can be done via ${__ENV__:FOO} in Alviss input)? +- Meta-data header in files for Directory rendering that controls file names and/or if they should be rendered or not +- Meta-data file for directories in Directory rendering that control the directory name? +- Proper Jinja2 Environment Template Loader to enable Jinja's include/extend stuff? +- Custom macros/scripts/filters? + +## Use Cases + +- From commandline (main use case, e.g. rendering CI/CD manifests) +- From code + +### Command Line Use Case Examples + +Using these as a basis for functionality (this is written before any actual code)! + +#### Example 1 + +```shell +$ ccp-stencil -i context.yaml -t template.html -o result.html +``` + +- Alviss file input: `-i context.yaml` +- Template file input: `-t template.html` +- Render file output: `-o result.html` + + +#### Example 2 + +```shell +$ ccp-stencil -a name=Bob -a age=7 -a color=Red -s "My name is {{name}} and I am {{age}} years old and my favorite color is {{color}}" +My name is Bob and I am 7 years old and my favorite color is Red +``` + +- Args input: `-a name=Bob -a age=7 -a color=Red` +- String template input: `-s "My name is {name} and I am {age} years old and my favorite color is {color}"` +- Print (stdout) output: _No argument, this is default!_ + + +#### Example 3 + +```shell +$ ccp-stencil -T templates/ -O build/ +``` + +- No context input: _No argument, this is default!_ +- Template directory input: `-T templates/` +- Render directory output: `-O build/` \ No newline at end of file diff --git a/_cli_out_test.txt b/_cli_out_test.txt new file mode 100644 index 0000000..ba957ea --- /dev/null +++ b/_cli_out_test.txt @@ -0,0 +1 @@ +My name is Bob the Cat and I am 57 years old and my favorite color is Blue \ No newline at end of file diff --git a/_sandbox_input.yaml b/_sandbox_input.yaml new file mode 100644 index 0000000..cf01685 --- /dev/null +++ b/_sandbox_input.yaml @@ -0,0 +1,5 @@ +name: Bob +age: 7 +colors: + favorite: Blue + weakness: Yellow \ No newline at end of file diff --git a/_sandbox_output.txt b/_sandbox_output.txt new file mode 100644 index 0000000..8b823ca --- /dev/null +++ b/_sandbox_output.txt @@ -0,0 +1 @@ +My name is Bob and I am 7 years old and my favorite color is NOT Blue \ No newline at end of file diff --git a/_sandbox_string.py b/_sandbox_string.py new file mode 100644 index 0000000..b71cae4 --- /dev/null +++ b/_sandbox_string.py @@ -0,0 +1,20 @@ +from ccpstencil import stencils + + +def main(): + # ctx = stencils.DictContext() + # ctx = stencils.DictContext({'name': 'Bob', 'age': 7, 'color': 'Red'}) + # ctx = stencils.KeyWordArgumentContext(name='Bob', age=7, color='Red') + ctx = stencils.AlvissContext('_sandbox_input.yaml') + + # tpl = stencils.StringTemplate('My name is {{name}} and I am {{age}} years old and my favorite color is {{color}}') + tpl = stencils.FileTemplate('_sandbox_template.txt') + + # rnd = stencils.StringRenderer(ctx, tpl) + # rnd = stencils.StdOutRenderer(ctx, tpl) + rnd = stencils.FileRenderer('./build/something/_sandbox_output.txt', ctx, tpl, overwrite=True) + print(rnd.render()) + + +if __name__ == '__main__': + main() diff --git a/_sandbox_template.txt b/_sandbox_template.txt new file mode 100644 index 0000000..6b124a2 --- /dev/null +++ b/_sandbox_template.txt @@ -0,0 +1 @@ +My name is {{name}} and I am {{age}} years old and my favorite color is NOT {{color}} \ No newline at end of file diff --git a/ccpstencil/__init__.py b/ccpstencil/__init__.py new file mode 100644 index 0000000..d21b194 --- /dev/null +++ b/ccpstencil/__init__.py @@ -0,0 +1,5 @@ +__version__ = '0.1.0' + +__author__ = 'Thordur Matthiasson ' +__license__ = 'MIT License' +__copyright__ = 'Copyright 2024 - CCP Games ehf' diff --git a/ccpstencil/cli/__init__.py b/ccpstencil/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ccpstencil/cli/ccp_stencil/__init__.py b/ccpstencil/cli/ccp_stencil/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ccpstencil/cli/ccp_stencil/_runner.py b/ccpstencil/cli/ccp_stencil/_runner.py new file mode 100644 index 0000000..b22826a --- /dev/null +++ b/ccpstencil/cli/ccp_stencil/_runner.py @@ -0,0 +1,55 @@ +__all__ = [ + 'StencilRunner', +] +from ccptools.structs import * +from ccpstencil.stencils import * +import logging +log = logging.getLogger(__file__) + + +class StencilRunner: + def __init__(self): + self.verbose: bool = False + + self.template: Optional[str] = None + self.string_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 + + def run(self): + rnd = self.get_renderer() + rnd.template = self.get_template() + rnd.context = self.get_context() + rnd.render() + + def get_template(self) -> ITemplate: + if self.template: + return FileTemplate(self.template) + else: + return StringTemplate(self.string_template) + + def _parse_arg(self, arg_str: str) -> Tuple[str, str]: + log.debug(f'_parse_arg({arg_str})') + return tuple(arg_str.split('=', maxsplit=1)) # noqa + + def get_context(self) -> IContext: + if self.input: + ctx = AlvissContext(self.input) + else: + ctx = DictContext() + + if self.additional_arg_list: + for arg in self.additional_arg_list: + ctx.nested_update(*self._parse_arg(arg)) + return ctx + + def get_renderer(self) -> IRenderer: + if self.output: + return FileRenderer(self.output, + overwrite=not self.no_overwrite) + else: + return StdOutRenderer() diff --git a/ccpstencil/cli/ccp_stencil/main.py b/ccpstencil/cli/ccp_stencil/main.py new file mode 100644 index 0000000..42a486c --- /dev/null +++ b/ccpstencil/cli/ccp_stencil/main.py @@ -0,0 +1,59 @@ +import argparse + +from ccpstencil import __version__ as version +from ccpstencil.structs import * +from ._runner import StencilRunner + +import sys + + +def main(): + parser = argparse.ArgumentParser(description='Renders a template using Jinja2 and Alviss input context', + epilog=f'CCP-Stencil version {version}') + + parser.add_argument('-v', '--verbose', action="store_true", help='Spits out DEBUG level logs') + + parser.add_argument('-i', '--input', nargs='?', help='Alviss file with input context (YAML or JSON)') + + parser.add_argument('-a', '--arg', action='append', help='Add additional Context arguments from the command line, e.g. -a foo=bar') + + parser.add_argument('-o', '--output', help='File to write the results to (otherwise its just printed to stdout)', + default='', nargs='?') + parser.add_argument('--no-overwrite', action="store_true", help='Makes sore existing output files are not overwritten') + + 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') + + args = parser.parse_args() + + if args.verbose: + import logging + logging.basicConfig(level=logging.DEBUG) + + log = logging.getLogger(__name__) + log.info('Verbose logging enabled') + log.info(f'Args: {args=}') + + runner = StencilRunner() + runner.verbose = args.verbose or False + runner.input = args.input or None + runner.output = args.output or None + runner.no_overwrite = args.no_overwrite or False + runner.template = args.template or None + runner.string_template = args.string_template or None + if args.arg: + for arg in args.arg: + runner.additional_arg_list.append(arg) + + try: + runner.run() + except StencilError as e: + print(f'An error occurred: {e!r}', file=sys.stderr) + sys.exit(3) + + +if __name__ == '__main__': + main() diff --git a/ccpstencil/context/__init__.py b/ccpstencil/context/__init__.py new file mode 100644 index 0000000..e576257 --- /dev/null +++ b/ccpstencil/context/__init__.py @@ -0,0 +1,4 @@ +from ccpstencil.structs.interfaces import IContext +from ._dict import * +from ._kwarg import * +from ._alviss import * diff --git a/ccpstencil/context/_alviss.py b/ccpstencil/context/_alviss.py new file mode 100644 index 0000000..8d5c99f --- /dev/null +++ b/ccpstencil/context/_alviss.py @@ -0,0 +1,31 @@ +__all__ = [ + 'AlvissContext', +] + +from ccpstencil.structs import * +from alviss import quickloader +from ccptools.tpu import iters +import logging +log = logging.getLogger(__file__) + + +class AlvissContext(IContext): + def __init__(self, file_name: str, **kwargs): + self._file_name = file_name + self._data = quickloader.autoload(file_name) + self._update_map: Dict[Tuple[str], Any] = {} + + def update(self, key: str, value: Any): + self._data[key] = value + + def nested_update(self, key_tuple: Union[str, Tuple[str]], value: Any): + log.debug(f'nested_update({key_tuple=}, {value=})') + if isinstance(key_tuple, str): + key_tuple = key_tuple.split('.') + if isinstance(key_tuple, List): + key_tuple = tuple(key_tuple) + 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/context/_dict.py b/ccpstencil/context/_dict.py new file mode 100644 index 0000000..77323e5 --- /dev/null +++ b/ccpstencil/context/_dict.py @@ -0,0 +1,27 @@ +__all__ = [ + 'DictContext', +] + +from ccpstencil.structs import * +from ccptools.tpu import iters +import logging +log = logging.getLogger(__file__) + + +class DictContext(IContext): + def __init__(self, context_map: Dict[str, Any] = None, **kwargs): + self._context_map = context_map or {} + + def update(self, key: str, value: Any): + self._context_map[key] = value + + def nested_update(self, key_tuple: Union[str, Tuple[str]], value: Any): + log.debug(f'nested_update({key_tuple=}, {value=})') + if isinstance(key_tuple, str): + key_tuple = key_tuple.split('.') + if isinstance(key_tuple, List): + key_tuple = tuple(key_tuple) + iters.nested_set(self._context_map, key_tuple, value) + + def as_dict(self) -> Dict: + return self._context_map diff --git a/ccpstencil/context/_kwarg.py b/ccpstencil/context/_kwarg.py new file mode 100644 index 0000000..cfaaddc --- /dev/null +++ b/ccpstencil/context/_kwarg.py @@ -0,0 +1,10 @@ +__all__ = [ + 'KeyWordArgumentContext', +] + +from ._dict import * + + +class KeyWordArgumentContext(DictContext): + def __init__(self, **kwargs): + super().__init__(context_map=kwargs) diff --git a/ccpstencil/renderer/__init__.py b/ccpstencil/renderer/__init__.py new file mode 100644 index 0000000..f023a0b --- /dev/null +++ b/ccpstencil/renderer/__init__.py @@ -0,0 +1,5 @@ +from ccpstencil.structs.interfaces import IRenderer +from ._base import * +from ._string import * +from ._stdout import * +from ._file import * diff --git a/ccpstencil/renderer/_base.py b/ccpstencil/renderer/_base.py new file mode 100644 index 0000000..08aca25 --- /dev/null +++ b/ccpstencil/renderer/_base.py @@ -0,0 +1,97 @@ +__all__ = [ + '_BaseRenderer', +] + +from ccpstencil.structs import * + +import jinja2 + +import logging +log = logging.getLogger(__file__) + + +class _BaseRenderer(IRenderer, abc.ABC): + _VALID_CONTEXTS: Optional[Tuple[Type[IContext]]] = None + _INVALID_CONTEXTS: Optional[Tuple[Type[IContext]]] = None + + _VALID_TEMPLATES: Optional[Tuple[Type[ITemplate]]] = None + _INVALID_TEMPLATES: Optional[Tuple[Type[ITemplate]]] = None + + def __init__(self, context: Optional[IContext] = None, template: Optional[ITemplate] = None, **kwargs): + self._context = None + self._template = None + # This is to trigger the Setters! + if context is not None: + self.context = context + if template is not None: + self.template = template + if kwargs: + log.warning(f'Unrecognized kwargs for {self.__class__.__name__}: {kwargs}') + self._env: jinja2.Environment = self._make_environment() + + def _make_environment(self) -> jinja2.Environment: + return jinja2.Environment( + lstrip_blocks=True, + trim_blocks=True, + undefined=jinja2.ChainableUndefined + ) + + def _pre_flight(self): + if not self.template: + raise NoTemplateSetError(f'No template set for {self.__class__.__name__}') + + def _is_valid_context(self, context: IContext) -> bool: + if self._INVALID_CONTEXTS: # Deny? + if isinstance(context, self._INVALID_CONTEXTS): + return False + + if self._VALID_CONTEXTS: # Allow? + if isinstance(context, self._VALID_CONTEXTS): + return True + return False # If there is an "allow list" then we deny everything else! + return True + + def _is_valid_template(self, template: ITemplate) -> bool: + if self._INVALID_TEMPLATES: # Deny? + if isinstance(template, self._INVALID_TEMPLATES): + return False + + if self._VALID_TEMPLATES: # Allow? + if isinstance(template, self._VALID_TEMPLATES): + return True + return False # If there is an "allow list" then we deny everything else! + return True + + @property + def context(self) -> Optional[IContext]: + return self._context + + @context.setter + def context(self, value: IContext): + if not self._is_valid_context(value): + raise InvalidContextTypeForRendererError(f'Context of {value.__class__.__name__} type does not work with a {self.__class__.__name__} Renderer') + self._context = value + + @property + def template(self) -> Optional[ITemplate]: + return self._template + + @template.setter + def template(self, value: ITemplate): + if not self._is_valid_template(value): + raise InvalidTemplateTypeForRendererError(f'Template of {value.__class__.__name__} type does not work with a {self.__class__.__name__} Renderer') + value.set_renderer(self) + self._template = value + + @abc.abstractmethod + def render(self): + """This should just be called by subclasses via super().render() in + order to run preflight and common stuff, but the empty return value + should be ignored. + """ + self._pre_flight() + + @property + def jinja_environment(self) -> jinja2.Environment: + return self._env + diff --git a/ccpstencil/renderer/_dir.py b/ccpstencil/renderer/_dir.py new file mode 100644 index 0000000..e69de29 diff --git a/ccpstencil/renderer/_file.py b/ccpstencil/renderer/_file.py new file mode 100644 index 0000000..cd3015d --- /dev/null +++ b/ccpstencil/renderer/_file.py @@ -0,0 +1,27 @@ +__all__ = [ + 'FileRenderer', +] + +from ccpstencil.structs import * +from pathlib import Path +from ._string import * + + +class FileRenderer(StringRenderer): + def __init__(self, output_file: Union[str, Path], + context: Optional[IContext] = None, template: Optional[ITemplate] = None, + overwrite: bool = True, + **kwargs): + if isinstance(output_file, str): + output_file = Path(output_file) + self._output_file: Path = output_file + self._overwrite = overwrite + super().__init__(context, template, **kwargs) + + def render(self) -> str: + if self._output_file.exists() and not self._overwrite: + raise OutputFileExistsError(f'The target output file already exists and overwriting is disabled: {self._output_file.absolute()}') + self._output_file.parent.mkdir(parents=True, exist_ok=True) + with open(self._output_file, 'w') as fout: + fout.write(super().render()) + return str(self._output_file.absolute()) diff --git a/ccpstencil/renderer/_stdout.py b/ccpstencil/renderer/_stdout.py new file mode 100644 index 0000000..96f6142 --- /dev/null +++ b/ccpstencil/renderer/_stdout.py @@ -0,0 +1,11 @@ +__all__ = [ + 'StdOutRenderer', +] + + +from ._string import * + + +class StdOutRenderer(StringRenderer): + def render(self): + print(super().render()) diff --git a/ccpstencil/renderer/_string.py b/ccpstencil/renderer/_string.py new file mode 100644 index 0000000..e529260 --- /dev/null +++ b/ccpstencil/renderer/_string.py @@ -0,0 +1,17 @@ +__all__ = [ + 'StringRenderer', +] + +from ccpstencil.structs import * +from ._base import * + + +class StringRenderer(_BaseRenderer): + def __init__(self, context: Optional[IContext] = None, template: Optional[ITemplate] = None, **kwargs): + super().__init__(context, template, **kwargs) + + def render(self) -> str: + super().render() + return self.template.get_jinja_template().render(**self.context.as_dict()) + + diff --git a/ccpstencil/stencils.py b/ccpstencil/stencils.py new file mode 100644 index 0000000..d54581b --- /dev/null +++ b/ccpstencil/stencils.py @@ -0,0 +1,3 @@ +from .context import * +from .template import * +from .renderer import * diff --git a/ccpstencil/structs/__init__.py b/ccpstencil/structs/__init__.py new file mode 100644 index 0000000..19a293e --- /dev/null +++ b/ccpstencil/structs/__init__.py @@ -0,0 +1,3 @@ +from ._base import * +from .interfaces import * +from ._errors import * diff --git a/ccpstencil/structs/_base.py b/ccpstencil/structs/_base.py new file mode 100644 index 0000000..a4c89f7 --- /dev/null +++ b/ccpstencil/structs/_base.py @@ -0,0 +1,2 @@ +from ccptools.structs import * +import jinja2 diff --git a/ccpstencil/structs/_errors.py b/ccpstencil/structs/_errors.py new file mode 100644 index 0000000..db70fb4 --- /dev/null +++ b/ccpstencil/structs/_errors.py @@ -0,0 +1,50 @@ +__all__ = [ + 'StencilError', + + 'TemplateError', + 'TemplateNotFoundError', + + 'ContextError', + + 'RenderError', + 'NoTemplateSetError', + 'InvalidContextTypeForRendererError', + 'InvalidTemplateTypeForRendererError', + 'OutputFileExistsError', +] + + +class StencilError(Exception): + pass + + +class TemplateError(StencilError): + pass + + +class TemplateNotFoundError(TemplateError, FileNotFoundError): + pass + + +class ContextError(StencilError): + pass + + +class RenderError(StencilError): + pass + + +class NoTemplateSetError(RenderError, ValueError): + pass + + +class InvalidContextTypeForRendererError(RenderError, TypeError): + pass + + +class InvalidTemplateTypeForRendererError(RenderError, TypeError): + pass + + +class OutputFileExistsError(RenderError, FileExistsError): + pass diff --git a/ccpstencil/structs/interfaces/__init__.py b/ccpstencil/structs/interfaces/__init__.py new file mode 100644 index 0000000..055757f --- /dev/null +++ b/ccpstencil/structs/interfaces/__init__.py @@ -0,0 +1,3 @@ +from ._context import * +from ._template import * +from ._renderer import * diff --git a/ccpstencil/structs/interfaces/_context.py b/ccpstencil/structs/interfaces/_context.py new file mode 100644 index 0000000..d506f37 --- /dev/null +++ b/ccpstencil/structs/interfaces/_context.py @@ -0,0 +1,22 @@ +__all__ = [ + 'IContext', +] +from ccptools.structs import * + + +class IContext(abc.ABC): + @abc.abstractmethod + def __init__(self, **kwargs): + pass + + @abc.abstractmethod + def update(self, key: str, value: Any): + pass + + @abc.abstractmethod + def nested_update(self, key_tuple: Union[str, Tuple[str]], value: Any): + pass + + @abc.abstractmethod + def as_dict(self) -> Dict: + pass diff --git a/ccpstencil/structs/interfaces/_renderer.py b/ccpstencil/structs/interfaces/_renderer.py new file mode 100644 index 0000000..c5de456 --- /dev/null +++ b/ccpstencil/structs/interfaces/_renderer.py @@ -0,0 +1,42 @@ +__all__ = [ + 'IRenderer', +] +from ccptools.structs import * +from ._context import * +from ._template import * +import jinja2 + + +class IRenderer(abc.ABC): + @abc.abstractmethod + def __init__(self, context: Optional[IContext] = None, template: Optional[ITemplate] = None, **kwargs): + pass + + @property + @abc.abstractmethod + def context(self) -> Optional[IContext]: + pass + + @context.setter + @abc.abstractmethod + def context(self, value: IContext): + pass + + @property + @abc.abstractmethod + def template(self) -> Optional[ITemplate]: + pass + + @template.setter + @abc.abstractmethod + def template(self, value: ITemplate): + pass + + @abc.abstractmethod + def render(self): + pass + + @property + @abc.abstractmethod + def jinja_environment(self) -> jinja2.Environment: + pass diff --git a/ccpstencil/structs/interfaces/_template.py b/ccpstencil/structs/interfaces/_template.py new file mode 100644 index 0000000..2bb5e3e --- /dev/null +++ b/ccpstencil/structs/interfaces/_template.py @@ -0,0 +1,24 @@ +__all__ = [ + 'ITemplate', +] +import abc +from typing import * +import jinja2 + +if TYPE_CHECKING: + from ._renderer import IRenderer + + +class ITemplate(abc.ABC): + @property + @abc.abstractmethod + def renderer(self) -> 'IRenderer': + pass + + @abc.abstractmethod + def set_renderer(self, renderer: 'IRenderer'): + pass + + @abc.abstractmethod + def get_jinja_template(self) -> jinja2.Template: + pass diff --git a/ccpstencil/template/__init__.py b/ccpstencil/template/__init__.py new file mode 100644 index 0000000..a9a171d --- /dev/null +++ b/ccpstencil/template/__init__.py @@ -0,0 +1,4 @@ +from ccpstencil.structs.interfaces import ITemplate +from ._base import * +from ._string import * +from ._file import * diff --git a/ccpstencil/template/_base.py b/ccpstencil/template/_base.py new file mode 100644 index 0000000..50675f9 --- /dev/null +++ b/ccpstencil/template/_base.py @@ -0,0 +1,16 @@ +__all__ = [ + '_BaseTemplate', +] +from ccpstencil.structs import * + + +class _BaseTemplate(ITemplate, abc.ABC): + def __init__(self, **kwargs): + self._renderer: Optional[IRenderer] = None + + @property + def renderer(self) -> IRenderer: + return self._renderer + + def set_renderer(self, renderer: IRenderer): + self._renderer = renderer diff --git a/ccpstencil/template/_file.py b/ccpstencil/template/_file.py new file mode 100644 index 0000000..bed62af --- /dev/null +++ b/ccpstencil/template/_file.py @@ -0,0 +1,23 @@ +__all__ = [ + 'FileTemplate', +] + +from ccpstencil.structs import * +from pathlib import Path + +from ._string import * + + +class FileTemplate(StringTemplate): + def __init__(self, file_path: Union[str, Path], **kwargs): + if isinstance(file_path, str): + file_path = Path(file_path) + self._file_path: Path = file_path + super().__init__(template_string=self._read_file(), **kwargs) + + def _read_file(self) -> str: + if not self._file_path.exists(): + raise TemplateNotFoundError(f'File {self._file_path} does not exist') + + with open(self._file_path, 'r') as fin: + return fin.read() diff --git a/ccpstencil/template/_string.py b/ccpstencil/template/_string.py new file mode 100644 index 0000000..c31738e --- /dev/null +++ b/ccpstencil/template/_string.py @@ -0,0 +1,16 @@ +__all__ = [ + 'StringTemplate', +] + +import jinja2 + +from ._base import * + + +class StringTemplate(_BaseTemplate): + def __init__(self, template_string: str, **kwargs): + super().__init__(**kwargs) + self._template_string = template_string + + def get_jinja_template(self) -> jinja2.Template: + return self.renderer.jinja_environment.from_string(self._template_string) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7fc8f1e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,57 @@ +[build-system] +requires = [ "setuptools>=42", "wheel" ] +build-backend = "setuptools.build_meta" + +[project] +name = "ccpstencil" +dynamic = ["version"] +description = "An Alviss powered Template renderer where the context input can be files and entire directory structures can be rendered." +readme = { file = "README.md", content-type = "text/markdown" } +license = { file = "LICENSE" } +authors = [ + { name = "Thordur Matthiasson", email = "thordurm@ccpgames.com" } +] +keywords = [ "alviss", "template", "render", "jinja2", "tools", "ccp", "utils" ] +classifiers = [ # https://pypi.org/classifiers/ + "Development Status :: 3 - Alpha", + + "License :: OSI Approved :: MIT License", + + "Intended Audience :: Developers", + + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + + "Topic :: Software Development", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Utilities" +] + +dependencies = [ + "ccptools >=1.1, <2", + "alviss >= 3.2, <4", + "jinja2 >= 3.1, <4" +] + +[project.scripts] +ccp-stencil = "ccpstencil.cli.ccp_stencil.main:main" + +[project.urls] +Homepage = "https://github.com/ccpgames/ccp-stencil" +Documentation = "https://github.com/ccpgames/ccp-stencil/blob/main/README.md" +Repository = "https://github.com/ccpgames/ccp-stencil.git" +Issues = "https://github.com/ccpgames/ccp-stencil/issues" +Changelog = "https://github.com/ccpgames/ccp-stencil/blob/main/CHANGELOG.md" + +[tool.setuptools.dynamic] +version = {attr = "ccpstencil.__version__"} + +[tool.setuptools.packages.find] +where = [ "." ] +exclude = [ "tests", "tests.*" ] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3012804 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +ccptools >=1.1, <2 +alviss >= 3.2, <4 +jinja2 >= 3.1, <4 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..81f4db7 --- /dev/null +++ b/setup.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python +import setuptools + + +if __name__ == '__main__': + setuptools.setup() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_some_basic_stuff.py b/tests/test_some_basic_stuff.py new file mode 100644 index 0000000..44ef10b --- /dev/null +++ b/tests/test_some_basic_stuff.py @@ -0,0 +1,32 @@ +import unittest + +from ccpstencil.stencils import * + + +class TestSomeBasicStuff(unittest.TestCase): + def test_basic_stuff(self): + context = DictContext({ + 'name': 'Bob', + 'age': 42, + 'colors': { + 'favorite': 'Red', + 'weakness': 'Yellow' + } + }) + template = StringTemplate("My name is {{name}} and I am {{age}} years old and my favorite color" + " is {{colors.favorite}} but I'm allergic to {{colors.weakness}}!") + + renderer = StringRenderer(context, template) + + self.assertEqual( + "My name is Bob and I am 42 years old and my favorite color" + " is Red but I'm allergic to Yellow!", renderer.render() + ) + + renderer.context.nested_update('colors.favorite', 'Blue') + + self.assertEqual( + "My name is Bob and I am 42 years old and my favorite color" + " is Blue but I'm allergic to Yellow!", renderer.render() + ) +