"""Cement core template module."""
import os
import pkgutil
import re
import shutil
import sys
from abc import abstractmethod
from pathlib import Path as _Path
from typing import Any
from ..core import exc
from ..core.handler import Handler
from ..core.interface import Interface
from ..utils import fs
from ..utils.misc import minimal_logger
LOG = minimal_logger(__name__)
LoadTemplateReturnType = tuple[bytes | str | None, str | None]
[docs]
class TemplateInterface(Interface):
"""
This class defines the Template Interface. Handlers that implement this
interface must provide the methods and attributes defined below. In
general, most implementations should sub-class from the provided
:class:`TemplateHandler` base class as a starting point.
"""
# D-09: template `data` is user-arbitrary — apps render templates with
# any shape of context dict. Public TemplateInterface (D-12).
[docs]
@abstractmethod
def render(self, content: str, data: dict[str, Any]) -> str | None:
"""
Render ``content`` as a template using the ``data`` dict.
Args:
content (str): The content to be rendered as a template.
data (dict): The data dictionary to render with template.
Returns:
str, None: The rendered template string, or ``None`` if nothing is rendered.
"""
pass # pragma: nocover # abstract method
# D-09: same user-arbitrary template-data contract as `render` above.
[docs]
@abstractmethod
def copy(self, src: str, dest: str, data: dict[str, Any]) -> bool:
"""
Render the ``src`` directory path, and copy to ``dest``. This method
must render directory and file **names** as template content, as well
as the contents of files.
Args:
src (str): The source template directory path.
dest (str): The destination directory path.
data (dict): The data dictionary to render with template.
Returns:
bool: Returns ``True`` if the copy completed successfully.
"""
pass # pragma: nocover # abstract method
[docs]
@abstractmethod
def load(self, path: str) -> tuple[str | bytes, str, str | None]:
"""
Loads a template file first from ``self.app._meta.template_dirs`` and
secondly from ``self.app._meta.template_module``. The
``template_dirs`` have presedence.
Args:
path (str): The secondary path of the template **after**
either ``template_module`` or ``template_dirs`` prefix (set via
``App.Meta``)
Returns:
tuple: The content of the template (``str``), the type of template
(``str``: ``directory``, or ``module``), and the path (``str``) of
the directory or module)
Raises:
cement.core.exc.FrameworkError: If the template does not exist in
either the ``template_module`` or ``template_dirs``.
"""
pass # pragma: nocover # abstract method
[docs]
class TemplateHandler(TemplateInterface, Handler):
"""
Base class that all template implementations should sub-class from.
Keyword arguments passed to this class will override meta-data options.
"""
# D-09: handler-contract pluggable kwargs (super() chain feeds Meta).
# Public TemplateHandler (D-12).
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
if self._meta.ignore is None:
self._meta.ignore = []
if self._meta.exclude is None:
self._meta.exclude = []
# D-09: same user-arbitrary template-data contract as the abstract
# `render` above (this is the concrete-handler-side declaration).
[docs]
def render(self, content: str | bytes, data: dict[str, Any]) -> str | None:
"""
Render ``content`` as template using using the ``data`` dictionary.
Args:
content (str): The content to render.
data (dict): The data dictionary to interpolate in the template.
Returns:
str, None: The rendered content, or ``None`` if nothing is rendered.
"""
# must be provided by a subclass
raise NotImplementedError # pragma: nocover # abstract method
def _match_patterns(self, item: str, patterns: list[str]) -> bool:
for pattern in patterns:
if re.match(pattern, item):
return True
return False
# D-09: same user-arbitrary template-data contract as `render` above.
[docs]
def copy(self,
src: str,
dest: str,
data: dict[str, Any],
force: bool = False,
exclude: list[str] | None = None,
ignore: list[str] | None = None) -> bool:
"""
Render ``src`` directory as template, including directory and file
names, and copy to ``dest`` directory.
Args:
src (str): The source directory path.
dest (str): The destination directory path.
data (dict): The data dictionary to interpolate in the template.
force (bool): Whether to overwrite existing files.
exclude (list): List of regular expressions to match files that
should only be copied, and not rendered as template.
ignore (list): List of regular expressions to match files that
should be completely ignored and not copied at all.
Returns:
bool: Returns ``True`` if the copy completed successfully.
Raises:
NotADirectoryError: If the ``src`` template path does not
exist or is not a directory.
AssertionError: When a ``dest`` file already exists and
``force is not True``.
"""
dest = fs.abspath(dest)
src = fs.abspath(src)
escaped_src = src.encode('unicode-escape').decode('utf-8')
# double escape for regex matching
encoded_src_pattern = escaped_src.encode('unicode-escape')
escaped_src_pattern = encoded_src_pattern.decode('utf-8')
if exclude is None:
exclude = []
if ignore is None:
ignore = []
ignore_patterns = self._meta.ignore + ignore
exclude_patterns = self._meta.exclude + exclude
if not _Path(src).is_dir():
raise NotADirectoryError(
f"Source path is not a directory: {src}"
)
dest_path = _Path(dest)
if not dest_path.exists():
dest_path.mkdir(parents=True)
LOG.debug(f'copying source template {src} -> {dest}')
# here's the fun
# os.walk retained — pathlib has no direct equivalent that yields
# the (cur_dir, sub_dirs, files) triple shape this loop depends
# on. Path.rglob('*') would require a wholesale loop restructure
# with higher regression risk than the boundary-tag accommodation
# (Phase 03 D-14 / Task 5 decision).
for cur_dir, sub_dirs, files in os.walk(src): # boundary: D-14
escaped_cur_dir = cur_dir.encode('unicode-escape').decode('utf-8')
cur_dir_stub: str
if cur_dir == '.':
continue # pragma: nocover # defensive: unreachable
elif cur_dir == src:
# don't render the source base dir (because we are telling it
# where to go as `dest`)
cur_dir_dest = dest
elif self._match_patterns(cur_dir, ignore_patterns):
LOG.debug(
f'not copying ignored directory: {cur_dir}')
continue
elif self._match_patterns(cur_dir, exclude_patterns):
LOG.debug(
'not rendering excluded directory as template: ' +
f'{cur_dir}')
cur_dir_stub = re.sub(escaped_src_pattern,
'',
escaped_cur_dir)
cur_dir_stub = cur_dir_stub.lstrip('/')
cur_dir_stub = cur_dir_stub.lstrip('\\\\') # noqa: B005 - intentional redundant strip preserved for behavioral parity (D-13)
cur_dir_stub = cur_dir_stub.lstrip('\\')
cur_dir_dest = str(_Path(dest) / cur_dir_stub)
else:
# render the cur dir
LOG.debug(
f'rendering directory as template: {cur_dir}')
cur_dir_stub = re.sub(escaped_src_pattern,
'',
escaped_cur_dir)
cur_dir_stub = self.render(cur_dir_stub, data) # type: ignore
cur_dir_stub = cur_dir_stub.lstrip('/')
cur_dir_stub = cur_dir_stub.lstrip('\\\\') # noqa: B005 - intentional redundant strip preserved for behavioral parity (D-13)
cur_dir_stub = cur_dir_stub.lstrip('\\')
cur_dir_dest = str(_Path(dest) / cur_dir_stub)
# render sub-dirs
for sub_dir in sub_dirs:
encoded_sub_dir = sub_dir.encode('unicode-escape')
escaped_sub_dir = encoded_sub_dir.decode('utf-8')
full_path = str(_Path(cur_dir) / sub_dir)
if self._match_patterns(full_path, ignore_patterns):
LOG.debug(
'not copying ignored sub-directory: ' +
f'{full_path}')
continue
elif self._match_patterns(full_path, exclude_patterns):
LOG.debug(
'not rendering excluded sub-directory as template: ' +
f'{full_path}')
sub_dir_dest = str(_Path(cur_dir_dest) / sub_dir)
else:
LOG.debug(
f'rendering sub-directory as template: {full_path}')
new_sub_dir = re.sub(escaped_src_pattern,
'',
self.render(escaped_sub_dir, data)) # type: ignore
sub_dir_dest = str(_Path(cur_dir_dest) / new_sub_dir)
sub_dir_dest_p = _Path(sub_dir_dest)
if not sub_dir_dest_p.exists():
LOG.debug(f'creating sub-directory {sub_dir_dest}')
sub_dir_dest_p.mkdir(parents=True)
for _file in files:
_rendered = self.render(_file, data)
new_file = re.sub(escaped_src_pattern, '', _rendered) # type: ignore
_file = fs.abspath(str(_Path(cur_dir) / _file))
_file_dest = fs.abspath(str(_Path(cur_dir_dest) / new_file))
# handle if destination path already exists
if _Path(_file_dest).exists():
if force is True:
LOG.debug(
f'overwriting existing file: {_file_dest} ')
else:
raise AssertionError(
f'Destination file already exists: {_file_dest} '
)
if self._match_patterns(_file, ignore_patterns):
LOG.debug(
'not copying ignored file: ' +
f'{_file}')
continue
elif self._match_patterns(_file, exclude_patterns):
LOG.debug(
'not rendering excluded file: ' +
f'{_file}')
shutil.copy(_file, _file_dest)
else:
LOG.debug(f'rendering file as template: {_file}')
f = open(_file)
content = f.read()
f.close()
_file_content = self.render(content, data)
f = open(_file_dest, 'w')
f.write(_file_content) # type: ignore
f.close()
return True
def _load_template_from_file(self,
template_path: str) -> LoadTemplateReturnType:
for template_dir in self.app._meta.template_dirs:
template_prefix = template_dir.rstrip('/')
template_path = template_path.lstrip('/')
full_path = fs.abspath(str(_Path(template_prefix) /
template_path))
LOG.debug(
f"attemping to load output template from file {full_path}")
if _Path(full_path).exists():
content = open(full_path).read()
LOG.debug(f"loaded output template from file {full_path}")
return (content, full_path)
else:
LOG.debug(f"output template file {full_path} does not exist")
continue
return (None, None)
def _load_template_from_module(self,
template_path: str) -> LoadTemplateReturnType:
template_module = self.app._meta.template_module
template_path = template_path.lstrip('/')
full_module_path = f"{template_module}.{re.sub('/', '.', template_path)}"
LOG.debug(
f"attemping to load output template '{template_path}' "
f"from module {template_module}"
)
# see if the module exists first
if template_module not in sys.modules:
try:
__import__(template_module, globals(), locals(), [], 0) # type: ignore
except ImportError:
LOG.debug(f"unable to import template module '{template_module}'.")
return (None, None)
# get the template content
try:
content = pkgutil.get_data(template_module, template_path) # type: ignore
LOG.debug(f"loaded output template '{template_path}' from module {template_module}")
return (content, full_module_path)
except OSError:
LOG.debug(
f"output template '{template_path}' does not exist "
f"in module {template_module}"
)
return (None, None)
[docs]
def load(self, template_path: str) -> tuple[str | bytes, str, str | None]:
"""
Loads a template file first from ``self.app._meta.template_dirs`` and
secondly from ``self.app._meta.template_module``. The
``template_dirs`` have presedence.
Args:
template_path (str): The secondary path of the template **after**
either ``template_module`` or ``template_dirs`` prefix (set via
``App.Meta``)
Returns:
tuple: The content of the template (``str``), the type of template
(``str``: ``directory``, or ``module``), and the path (``str``) of
the directory or module)
Raises:
cement.core.exc.FrameworkError: If the template does not exist in
either the ``template_module`` or ``template_dirs``.
"""
if not template_path:
raise exc.FrameworkError(f"Invalid template path '{template_path}'.")
# first attempt to load from file
content: str | bytes | None
content, path = self._load_template_from_file(template_path)
if content is None:
# second attempt to load from module
content, path = self._load_template_from_module(template_path)
template_type = 'module'
else:
template_type = 'directory'
# if content is None, that means we didn't find a template file in
# either and that is an exception
if content is None:
raise exc.FrameworkError(f"Could not locate template: {template_path}")
return (content, template_type, path)