batstencil 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. batstencil/__init__.py +5 -0
  2. batstencil/__main__.py +4 -0
  3. batstencil/cli/__init__.py +0 -0
  4. batstencil/cli/bat_stencil/__init__.py +0 -0
  5. batstencil/cli/bat_stencil/_runner.py +88 -0
  6. batstencil/cli/bat_stencil/main.py +80 -0
  7. batstencil/context/__init__.py +4 -0
  8. batstencil/context/_allconf.py +40 -0
  9. batstencil/context/_dict.py +25 -0
  10. batstencil/context/_kwarg.py +10 -0
  11. batstencil/jinjaext/__init__.py +0 -0
  12. batstencil/jinjaext/extensions/__init__.py +4 -0
  13. batstencil/jinjaext/extensions/_embed.py +54 -0
  14. batstencil/jinjaext/extensions/_repeat_for.py +44 -0
  15. batstencil/jinjaext/extensions/_setfilename.py +38 -0
  16. batstencil/jinjaext/extensions/_skip_if.py +24 -0
  17. batstencil/jinjaext/filters/__init__.py +1 -0
  18. batstencil/jinjaext/filters/_base64.py +13 -0
  19. batstencil/renderer/__init__.py +6 -0
  20. batstencil/renderer/_base.py +283 -0
  21. batstencil/renderer/_dir.py +253 -0
  22. batstencil/renderer/_file.py +72 -0
  23. batstencil/renderer/_stdout.py +14 -0
  24. batstencil/renderer/_string.py +22 -0
  25. batstencil/shortcuts/__init__.py +1 -0
  26. batstencil/shortcuts/_render_stencil.py +19 -0
  27. batstencil/stencils.py +6 -0
  28. batstencil/structs/__init__.py +4 -0
  29. batstencil/structs/_aliases.py +7 -0
  30. batstencil/structs/_base.py +2 -0
  31. batstencil/structs/_errors.py +95 -0
  32. batstencil/structs/interfaces/__init__.py +3 -0
  33. batstencil/structs/interfaces/_context.py +24 -0
  34. batstencil/structs/interfaces/_renderer.py +61 -0
  35. batstencil/structs/interfaces/_template.py +24 -0
  36. batstencil/template/__init__.py +5 -0
  37. batstencil/template/_base.py +16 -0
  38. batstencil/template/_dir.py +122 -0
  39. batstencil/template/_file.py +53 -0
  40. batstencil/template/_string.py +16 -0
  41. batstencil/utils/__init__.py +1 -0
  42. batstencil/utils/_guessers.py +63 -0
  43. batstencil-1.0.0.dist-info/METADATA +406 -0
  44. batstencil-1.0.0.dist-info/RECORD +48 -0
  45. batstencil-1.0.0.dist-info/WHEEL +5 -0
  46. batstencil-1.0.0.dist-info/entry_points.txt +2 -0
  47. batstencil-1.0.0.dist-info/licenses/LICENSE +22 -0
  48. batstencil-1.0.0.dist-info/top_level.txt +1 -0
batstencil/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ __version__ = '1.0.0'
2
+
3
+ __author__ = 'Thordur Matthiasson <doddi79@gmail.com>'
4
+ __license__ = 'MIT License'
5
+ __copyright__ = 'Copyright 2024 - CCP Games ehf, 2026 - Thordur Matthiasson'
batstencil/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from batstencil.cli.bat_stencil.main import main
2
+
3
+ if __name__ == '__main__':
4
+ main()
File without changes
File without changes
@@ -0,0 +1,88 @@
1
+ __all__ = [
2
+ 'StencilRunner',
3
+ ]
4
+ import os
5
+ import sys
6
+ from batstencil.structs import *
7
+ from batstencil.stencils import *
8
+ import logging
9
+ log = logging.getLogger(__name__)
10
+
11
+
12
+ class StencilRunner:
13
+ """Runs the `bat-stencil` command line (also usable from code)"""
14
+ def __init__(self):
15
+ self.verbose: bool = False
16
+
17
+ self.template: Optional[str] = None
18
+ self.string_template: Optional[str] = None
19
+ self.directory_template: Optional[str] = None
20
+
21
+ self.input: Optional[str] = None
22
+ self.additional_arg_list: List[str] = []
23
+
24
+ self.output: Optional[str] = None
25
+ self.no_overwrite: bool = False
26
+ self.no_purge: bool = False
27
+
28
+ def _make_cwd_importable(self):
29
+ cwd = os.getcwd()
30
+ log.debug(f'os.getcwd()={cwd}')
31
+ if cwd not in sys.path:
32
+ sys.path.append(cwd)
33
+
34
+ def run(self):
35
+ self._make_cwd_importable()
36
+ rnd = self.get_renderer()
37
+ rnd.template = self.get_template()
38
+ rnd.context = self.get_context()
39
+ res = rnd.render()
40
+ if self.output:
41
+ if isinstance(res, list):
42
+ for f in res:
43
+ print(f'Wrote file: {f}')
44
+ elif res is None:
45
+ print('Skipped rendering (skip_if)')
46
+ else:
47
+ print(f'Wrote file: {res}')
48
+
49
+ def get_template(self) -> ITemplate:
50
+ if self.directory_template:
51
+ return DirectoryTemplate(self.directory_template)
52
+ elif self.template:
53
+ return FileTemplate(self.template)
54
+ else:
55
+ return StringTemplate(self.string_template)
56
+
57
+ def _parse_arg(self, arg_str: str) -> Tuple[str, str]:
58
+ log.debug(f'_parse_arg({arg_str})')
59
+ if '=' not in arg_str:
60
+ raise ContextError(f'Invalid argument "{arg_str}", expected key=value (e.g. -a foo=bar)')
61
+ key, value = arg_str.split('=', maxsplit=1)
62
+ if not key.strip():
63
+ raise ContextError(f'Invalid argument "{arg_str}", the key is empty')
64
+ return key.strip(), value
65
+
66
+ def get_context(self) -> IContext:
67
+ if self.input:
68
+ ctx = AllConfContext(self.input)
69
+ else:
70
+ ctx = DictContext()
71
+
72
+ if self.additional_arg_list:
73
+ for arg in self.additional_arg_list:
74
+ ctx.nested_update(*self._parse_arg(arg))
75
+ return ctx
76
+
77
+ def get_renderer(self) -> IRenderer:
78
+ if self.directory_template:
79
+ if not self.output:
80
+ raise RenderError('Must specify output directory for directory rendering')
81
+ return DirectoryRenderer(output_path=self.output,
82
+ overwrite=not self.no_overwrite,
83
+ purge=not self.no_purge)
84
+ elif self.output:
85
+ return FileRenderer(self.output,
86
+ overwrite=not self.no_overwrite)
87
+ else:
88
+ return StdOutRenderer()
@@ -0,0 +1,80 @@
1
+ import argparse
2
+
3
+ from batstencil import __version__ as version
4
+ from batstencil.structs import *
5
+ from ._runner import StencilRunner
6
+
7
+ import sys
8
+
9
+
10
+ def main():
11
+ parser = argparse.ArgumentParser(prog='bat-stencil',
12
+ description='Renders a template (or a directory of templates) using Jinja2'
13
+ ' with an AllConf (YAML/JSON) input context',
14
+ epilog=f'bat-stencil version {version}')
15
+
16
+ parser.add_argument('--version', action='version', version=f'%(prog)s {version}')
17
+
18
+ parser.add_argument('-v', '--verbose', action="store_true", help='Spits out DEBUG level logs')
19
+
20
+ parser.add_argument('-i', '--input', nargs='?', help='AllConf file with input context (YAML or JSON)')
21
+
22
+ parser.add_argument('-a', '--arg', action='append',
23
+ help='Add additional Context arguments from the command line, e.g. -a foo=bar'
24
+ ' (nested keys with dots, e.g. -a app.name=bar)')
25
+
26
+ parser.add_argument('-o', '--output', help='Path or file to write the results to (otherwise its just printed to stdout).'
27
+ ' If this is a path (ends with /), the name of the rendered file will be the same as the input template.',
28
+ default='', nargs='?')
29
+ parser.add_argument('--no-overwrite', action="store_true", help='Makes sure existing output files are not overwritten')
30
+ parser.add_argument('--no-purge', action="store_true", help='Skips purging the output path when rendering directories')
31
+
32
+ input_group = parser.add_mutually_exclusive_group(required=True)
33
+ input_group.add_argument('-t', '--template', default='',
34
+ help='Template file to render')
35
+ input_group.add_argument('-s', '--string-template', default='',
36
+ help='Supply a string directly from the command line to use as a template instead of a file')
37
+ input_group.add_argument('-d', '--directory-template', default='',
38
+ help='Root directory of files and directories to render. This requires the output argument to be a directory')
39
+
40
+ args = parser.parse_args()
41
+
42
+ if args.verbose:
43
+ import logging
44
+ logging.basicConfig(level=logging.DEBUG)
45
+
46
+ log = logging.getLogger(__name__)
47
+ log.info('Verbose logging enabled')
48
+ log.info(f'Args: {args=}')
49
+
50
+ runner = StencilRunner()
51
+ runner.verbose = args.verbose or False
52
+ runner.input = args.input or None
53
+ runner.output = args.output or None
54
+ runner.no_overwrite = args.no_overwrite or False
55
+ runner.no_purge = args.no_purge or False
56
+ runner.template = args.template or None
57
+ runner.string_template = args.string_template or None
58
+ runner.directory_template = args.directory_template or None
59
+ if args.arg:
60
+ for arg in args.arg:
61
+ runner.additional_arg_list.append(arg)
62
+
63
+ try:
64
+ runner.run()
65
+ except StencilError as e:
66
+ print(f'An error occurred: {e}', file=sys.stderr)
67
+ if e.__cause__ is not None and args.verbose:
68
+ raise
69
+ sys.exit(3)
70
+ except jinja2.TemplateError as e:
71
+ where = f' in {e.filename}' if getattr(e, 'filename', None) else ''
72
+ line = f' (line {e.lineno})' if getattr(e, 'lineno', None) else ''
73
+ print(f'Template error{where}{line}: {e.__class__.__name__}: {e}', file=sys.stderr)
74
+ if args.verbose:
75
+ raise
76
+ sys.exit(3)
77
+
78
+
79
+ if __name__ == '__main__':
80
+ main()
@@ -0,0 +1,4 @@
1
+ from batstencil.structs.interfaces import IContext
2
+ from ._dict import *
3
+ from ._kwarg import *
4
+ from ._allconf import *
@@ -0,0 +1,40 @@
1
+ __all__ = [
2
+ 'AllConfContext',
3
+ 'AlvissContext',
4
+ ]
5
+
6
+ from batstencil.structs import *
7
+ from allconf import quickloader
8
+ from batutils.tpu import iters
9
+ from pathlib import Path
10
+ import logging
11
+ log = logging.getLogger(__name__)
12
+
13
+
14
+ class AllConfContext(IContext):
15
+ """A context loaded from an AllConf (JSON/YAML) file, with all of AllConf's
16
+ bells and whistles (includes, extensions, internal references,
17
+ `${__ENV__:...}`, `${__PY__:...}` etc.)."""
18
+ def __init__(self, file_name: Union[str, Path], **kwargs):
19
+ self._file_name = str(file_name)
20
+ self._data = quickloader.autoload(self._file_name)
21
+
22
+ @property
23
+ def file_name(self) -> str:
24
+ return self._file_name
25
+
26
+ def update(self, key: str, value: Any):
27
+ self.as_dict()[key] = value
28
+
29
+ def nested_update(self, key_tuple: Union[str, Sequence[str]], value: Any):
30
+ log.debug(f'nested_update({key_tuple=}, {value=})')
31
+ if isinstance(key_tuple, str):
32
+ key_tuple = key_tuple.split('.')
33
+ iters.nested_set(self.as_dict(), list(key_tuple), value)
34
+
35
+ def as_dict(self) -> Dict:
36
+ return self._data.as_dict(unmaksed=True)
37
+
38
+
39
+ AlvissContext = AllConfContext
40
+ """Deprecated alias of `AllConfContext` (from back when AllConf was Alviss)"""
@@ -0,0 +1,25 @@
1
+ __all__ = [
2
+ 'DictContext',
3
+ ]
4
+
5
+ from batstencil.structs import *
6
+ from batutils.tpu import iters
7
+ import logging
8
+ log = logging.getLogger(__name__)
9
+
10
+
11
+ class DictContext(IContext):
12
+ def __init__(self, context_map: Optional[Dict[str, Any]] = None, **kwargs):
13
+ self._context_map = context_map if context_map is not None else {}
14
+
15
+ def update(self, key: str, value: Any):
16
+ self._context_map[key] = value
17
+
18
+ def nested_update(self, key_tuple: Union[str, Sequence[str]], value: Any):
19
+ log.debug(f'nested_update({key_tuple=}, {value=})')
20
+ if isinstance(key_tuple, str):
21
+ key_tuple = key_tuple.split('.')
22
+ iters.nested_set(self._context_map, list(key_tuple), value)
23
+
24
+ def as_dict(self) -> Dict:
25
+ return self._context_map
@@ -0,0 +1,10 @@
1
+ __all__ = [
2
+ 'KeyWordArgumentContext',
3
+ ]
4
+
5
+ from ._dict import *
6
+
7
+
8
+ class KeyWordArgumentContext(DictContext):
9
+ def __init__(self, **kwargs):
10
+ super().__init__(context_map=kwargs)
File without changes
@@ -0,0 +1,4 @@
1
+ from ._embed import *
2
+ from ._skip_if import *
3
+ from ._setfilename import *
4
+ from ._repeat_for import *
@@ -0,0 +1,54 @@
1
+ __all__ = [
2
+ 'EmbedExtension',
3
+ ]
4
+
5
+ from jinja2 import nodes
6
+ from jinja2.ext import Extension
7
+ from batstencil.structs import *
8
+
9
+
10
+ class EmbedExtension(Extension):
11
+ """Adds the `{% embed 'file' %}` tag that embeds a file "as-is" (not as a
12
+ template), with these optional arguments:
13
+
14
+ - `indent=N` to indent every line by N spaces (e.g. for YAML)
15
+ - `allconf=True` to render the file with AllConf first (`alviss=True` is a
16
+ deprecated alias), along with `env=True`, `fidelius=True` and `py=True`
17
+ to resolve `${__ENV__:...}`, `${__FID__:...}` and `${__PY__:...}`
18
+ expressions
19
+ """
20
+ tags = {'embed'}
21
+
22
+ def __init__(self, environment):
23
+ super().__init__(environment)
24
+ environment.extend(stencil_renderer=None)
25
+
26
+ @property
27
+ def renderer(self) -> IRenderer:
28
+ return self.environment.stencil_renderer # noqa
29
+
30
+ def parse(self, parser):
31
+ lineno = next(parser.stream).lineno
32
+ args = [parser.parse_expression()]
33
+ source_file = parser.filename
34
+ kwargs = [nodes.Keyword('source_file', nodes.Const(source_file))]
35
+ while parser.stream.skip_if('comma'):
36
+ key = parser.stream.expect('name').value
37
+ parser.stream.expect('assign')
38
+ value = parser.parse_expression()
39
+ kwargs.append(nodes.Keyword(key, value))
40
+
41
+ return nodes.CallBlock(self.call_method('_render_embed', args, kwargs), [], [], []).set_lineno(lineno)
42
+
43
+ def _render_embed(self, file_path, source_file: Optional[str] = None, indent: int = 0,
44
+ caller=None, **kwargs):
45
+
46
+ # Check if file_path is a variable in the context
47
+ if callable(file_path):
48
+ file_path = file_path()
49
+
50
+ content = self.renderer.get_embed(file_path, source_file=source_file, **kwargs)
51
+
52
+ indent_str = ' ' * int(indent or 0)
53
+
54
+ return f'{indent_str}{indent_str.join(content.splitlines(True))}\n'
@@ -0,0 +1,44 @@
1
+ __all__ = [
2
+ 'RepeatForExtension',
3
+ ]
4
+ from jinja2 import nodes
5
+ from jinja2.ext import Extension
6
+ from batstencil.structs import *
7
+
8
+
9
+ class RepeatForExtension(Extension):
10
+ """Adds the `{% repeat_for item in items %}` tag (also
11
+ `{% repeat_for key, value in some_dict.items() %}`) that makes the
12
+ `DirectoryRenderer` render the template, or the directory when used in a
13
+ `__stencil_meta__` file, once per item, with the loop variable(s) added to
14
+ the context (and loop info in `__stencil__.loop`).
15
+
16
+ Use `{% setfilename %}` along with it to give each copy a unique name.
17
+ """
18
+ tags = {'repeat_for'}
19
+
20
+ def __init__(self, environment):
21
+ super().__init__(environment)
22
+ environment.extend(stencil_renderer=None)
23
+
24
+ def parse(self, parser):
25
+ lineno = next(parser.stream).lineno
26
+ target = parser.parse_assign_target(extra_end_rules=('name:in',))
27
+ if isinstance(target, nodes.Name):
28
+ names = [target.name]
29
+ elif isinstance(target, nodes.Tuple) and all(isinstance(n, nodes.Name) for n in target.items):
30
+ names = [n.name for n in target.items]
31
+ else:
32
+ parser.fail('repeat_for expects a variable name (or names) like: repeat_for item in items', lineno)
33
+ parser.stream.expect('name:in')
34
+ iterable = parser.parse_expression()
35
+ return nodes.CallBlock(self.call_method('_repeat_for', [nodes.Const(names), iterable]), # noqa
36
+ [], [], []).set_lineno(lineno)
37
+
38
+ def _repeat_for(self, names: List[str], iterable, caller):
39
+ renderer = self.environment.stencil_renderer # noqa
40
+ if getattr(renderer, 'repeat_mode', None) == 'ignore':
41
+ return '' # The loop item has already been set
42
+ if isinstance(iterable, jinja2.Undefined) or iterable is None:
43
+ iterable = []
44
+ raise RepeatRendering(names, iterable)
@@ -0,0 +1,38 @@
1
+ __all__ = [
2
+ 'SetFilenameExtension',
3
+ ]
4
+
5
+ from jinja2 import nodes
6
+ from jinja2.ext import Extension
7
+ from batstencil.structs import *
8
+ import logging
9
+ log = logging.getLogger(__name__)
10
+
11
+
12
+ class SetFilenameExtension(Extension):
13
+ """Adds the `{% setfilename %}...{% endsetfilename %}` tag that sets the
14
+ name of the rendered file (or directory, in `__stencil_meta__` files) to
15
+ whatever its body renders as (stripped)."""
16
+ tags = {'setfilename'}
17
+
18
+ def __init__(self, environment):
19
+ super().__init__(environment)
20
+ environment.extend(stencil_renderer=None)
21
+
22
+ @property
23
+ def renderer(self) -> IRenderer:
24
+ return self.environment.stencil_renderer # noqa
25
+
26
+ def parse(self, parser):
27
+ lineno = next(parser.stream).lineno
28
+ body = parser.parse_statements(('name:endsetfilename',), drop_needle=True)
29
+ return nodes.CallBlock(self.call_method('_set_filename', [nodes.Const(parser.filename)]),
30
+ [], [], body).set_lineno(lineno)
31
+
32
+ def _set_filename(self, source_file: Optional[str], caller):
33
+ name = caller().strip()
34
+ if not name:
35
+ log.warning(f'The setfilename tag in {source_file or "a template"} rendered an empty file name'
36
+ f' (check the template code and context), so it is ignored and the default name is used')
37
+ self.renderer.output_file_name = name
38
+ return ''
@@ -0,0 +1,24 @@
1
+ __all__ = [
2
+ 'SkipIfExtension',
3
+ ]
4
+ from jinja2 import nodes
5
+ from jinja2.ext import Extension
6
+ from batstencil.structs import *
7
+
8
+
9
+ class SkipIfExtension(Extension):
10
+ """Adds the `{% skip_if <condition> %}` tag that skips rendering the
11
+ template (or directory, in `__stencil_meta__` files) if the condition is
12
+ true."""
13
+ tags = {'skip_if'}
14
+
15
+ def parse(self, parser):
16
+ lineno = next(parser.stream).lineno
17
+ # Parse the condition expression
18
+ condition = parser.parse_expression()
19
+ return nodes.CallBlock(self.call_method('_check_condition', [condition]), [], [], []).set_lineno(lineno)
20
+
21
+ def _check_condition(self, condition, caller):
22
+ if condition:
23
+ raise CancelRendering('Rendering cancelled due to a skip_if condition being true')
24
+ return ''
@@ -0,0 +1 @@
1
+ from ._base64 import *
@@ -0,0 +1,13 @@
1
+ __all__ = [
2
+ 'base64',
3
+ ]
4
+
5
+ import base64 as _base64
6
+ from typing import *
7
+
8
+
9
+ def base64(value: Union[str, bytes]) -> str:
10
+ """Base64 encodes a value (e.g. for Kubernetes Secrets) as a single line"""
11
+ if not isinstance(value, bytes):
12
+ value = str(value).encode('utf-8')
13
+ return _base64.b64encode(value).decode('ascii')
@@ -0,0 +1,6 @@
1
+ from batstencil.structs.interfaces import IRenderer
2
+ from ._base import *
3
+ from ._string import *
4
+ from ._stdout import *
5
+ from ._file import *
6
+ from ._dir import *