import re
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Type, Union
from django.conf import settings
from lxml import etree
from exporter.document import Document
from exporter.exceptions import (
CheckNotComputedError,
MissingArgumentError,
TagArgumentError,
TagError,
TagSyntaxError,
UnknownTagError,
)
from exporter.gdocs import Gdocs
from exporter.util import quote_list
[docs]
class Tag:
"""
A tag for inserting content into a template.
:param gdocs: a Google API client
:param dataset_id: the dataset's ID
"""
#: The tag's name.
name: Optional[str] = None
#: The names of all arguments.
argument_names: Set[str] = set()
#: The names of required arguments.
argument_required: Set[str] = set()
#: A mapping of argument names to validation functions.
argument_validators: Dict[str, Callable[[str], bool]] = {}
#: A mapping of argument names to failure messages.
argument_validation_messages: Dict[str, str] = {}
#: A mapping of argument names to conversion functions.
argument_converters: Dict[str, Callable[[str], Any]] = {}
# . A mapping of argument names to default values.
argument_defaults: Dict[str, Any] = {}
def __init__(self, gdocs: Gdocs, dataset_id: int):
self.gdocs = gdocs
self.dataset_id = dataset_id
self.arguments: Dict[str, Any] = {}
# Do not call after `finalize_arguments`.
[docs]
def set_argument(self, name: str, value: Any) -> None:
"""
Set an argument.
The argument's name is checked against declared arguments, and its value is checked with the declared
validator, if any. Its value is converted with the declared converter, if any.
:param name: the argument's name
:param value: the argument's value
:raises TagArgumentError: if the name is unrecognized or the value is invalid
"""
if name not in self.argument_names:
if self.argument_names:
suffix = f"The argument must be one of: {quote_list(self.argument_names)}."
else:
suffix = "This tag does not accept any arguments."
raise TagArgumentError(f"The argument '{name}' is not valid. {suffix}")
if name in self.argument_validators and not self.argument_validators[name](value):
raise TagArgumentError(
f"The value '{value}' for argument '{name}' is not valid. {self.argument_validation_messages[name]}"
)
if name in self.argument_converters:
value = self.argument_converters[name](value)
self.arguments[name] = value
[docs]
def finalize_arguments(self) -> None:
"""
Set unused arguments to default values.
:raises TagArgumentError: if a value is invalid
"""
for name, default in self.argument_defaults.items():
if name not in self.arguments:
self.set_argument(name, default)
# Do not call before `finalize_arguments`.
[docs]
def validate_and_render(self, data: Dict[str, Any]) -> Any:
"""
Check for missing arguments, and call and return :meth:`~exporter.tag.Tag.render`.
:raises MissingArgumentError: if arguments are missing
"""
missing = [name for name in self.argument_required if name not in self.arguments]
if missing:
raise MissingArgumentError(f"Missing argument(s) for tag {self.name}: {quote_list(missing)}.")
return self.render(data)
[docs]
def render(self, data: Dict[str, Any]) -> Any:
"""
Render the tag.
:param data: the data ("context") provided by another tag
"""
raise NotImplementedError
# Keep this class, because render()'s signature differs between LeafTag and TemplateTag.
[docs]
class LeafTag(Tag):
"""
A leaf tag renders itself, using the data ("context") provided by a template tag.
"""
[docs]
def render(self, data: Dict[str, Any]) -> Union[str, etree.Element, List[etree.Element]]:
"""
Render the tag.
:param data: the data ("context") provided by a template tag
"""
raise NotImplementedError
[docs]
class TemplateTag(Tag):
"""
A template tag renders a template, stored as a document in Google Docs. The template can contain sub-tags.
:param gdocs: a Google API client
:param dataset_id: the dataset's ID
"""
#: The default value of the ``template`` argument.
default_template: Optional[str] = None
#: The sub-tags supported by the template tag.
tags: Tuple[Type[Tag], ...] = ()
def __init__(self, gdocs: Gdocs, dataset_id: int):
super().__init__(gdocs, dataset_id)
self.argument_names.add("template")
self.argument_required.add("template")
self.argument_defaults["template"] = self.default_template
[docs]
def get_context(self) -> Dict[str, Any]:
"""
Return the data ("context") to be provided to sub-tags.
"""
return {}
[docs]
def render(self, data: Dict[str, Any]) -> Tuple[etree.Element, List[str]]:
"""
Read the template's content, extract its sub-tags, recursively call the sub-tags'
:meth:`~exporter.tag.Tag.validate_and_render` method, and merge the results into the content.
:param data: the data ("context") provided by another template tag
:raises MissingArgumentError: if a leaf tag's argument is missing
:raises ValueError: if a leaf tag's :meth:`~exporter.tag.LeafTag.render` method has an invalid return value
:raises TagError: if a template tag or its sub-tags caused an error
"""
document = Document(self.gdocs.get_content(self.arguments["template"]))
new_data = self.get_context()
tags_mapping, failed_tags = self.get_tags_mapping(document.get_tags())
for full_tag, tag in tags_mapping.items():
if isinstance(tag, TemplateTag):
try:
result, sub_failed_tags = tag.validate_and_render(new_data)
failed_tags += sub_failed_tags
except TagError as er:
raise er.fill(full_tag, self.arguments["template"])
except CheckNotComputedError as er:
document.set_text("Element could not be computed", full_tag)
failed_tags.append(str(er))
continue
document.merge(result, full_tag)
else:
try:
result = tag.validate_and_render(new_data)
except MissingArgumentError as er:
raise er.fill(full_tag, self.arguments["template"])
if isinstance(result, list) and all(isinstance(el, etree._Element) for el in result):
document.set_elements(result, full_tag)
elif isinstance(result, etree._Element):
document.set_element(result, full_tag)
elif isinstance(result, str):
document.set_text(result, full_tag)
else:
raise ValueError(
"LeafTag's render method must return of the following types: "
"list of 'etree.Element', 'etree.Element', 'str'."
)
return document.content, failed_tags
[docs]
@dataclass
class TagExpression:
"""
A representation of the tag as expressed in the template.
:param name: The tag's name
:param arguments: The tag's arguments
"""
name: str
arguments: Dict[str, str]
[docs]
@classmethod
def parse(cls, string: str) -> "TagExpression":
"""
Parse the string as a tag.
:param string: the full tag, starting with ``{%``
:raises TagSyntaxError: if the tag is malformed
"""
if string[-2:] != "%}":
raise TagSyntaxError(
"The tag is unclosed. Ensure the full tag is on one line, without style changes. Format: "
"{% tag name1:|value1| name2:|value2| ... %}"
)
tokens = string[2:-2].split()
if not tokens:
raise TagSyntaxError("The tag contains no tokens.")
arguments = {}
for token in tokens[1:]:
match = re.search(r"^(\w+):\|([^|]+)\|$", token)
if not match:
raise TagSyntaxError(f"The argument '{token}' is malformed. Format: name:|value|")
name, value = match.groups()
if name in arguments:
raise TagSyntaxError(f"The argument '{name}' is repeated.")
arguments[name] = value
return cls(tokens[0], arguments)
[docs]
def template(
_name: str, _default_template: str, _tags: Tuple[Type[Tag], ...]
) -> Callable[[Callable[[TemplateTag], Dict[str, Any]]], Type[TemplateTag]]:
"""
Build a :class:`~exporter.tag.TemplateTag` by decorating a
:meth:`~exporter.tag.TemplateTag.get_context` implementation.
:param _name: the tag's name
:param _default_template: the default value of the ``template`` argument
:param _tags: the tag's sub-tags
"""
def _template(function: Callable[[TemplateTag], Dict[str, Any]]) -> Type[TemplateTag]:
class _Tag(TemplateTag):
name = _name
argument_names = set()
argument_required = set()
argument_validators = {}
argument_validation_messages = {}
argument_converters = {}
argument_defaults = {}
default_template = _default_template
tags = _tags
def get_context(self) -> Dict[str, Any]:
return function(self)
return _Tag
return _template
[docs]
def leaf(
_name: str,
) -> Callable[[Callable[[LeafTag, Dict[str, Any]], Union[str, etree.Element, List[etree.Element]]]], Type[LeafTag]]:
"""
Build a :class:`~exporter.tag.LeafTag` by decorating a
:meth:`~exporter.tag.LeafTag.render` implementation.
:param _name: the tag's name
"""
def _leaf(
function: Callable[[LeafTag, Dict[str, Any]], Union[str, etree.Element, List[etree.Element]]]
) -> Type[LeafTag]:
class _Tag(LeafTag):
name = _name
argument_names = set()
argument_required = set()
argument_validators = {}
argument_validation_messages = {}
argument_converters = {}
argument_defaults = {}
def render(self, data: Dict[str, Any]) -> Union[str, etree.Element, List[etree.Element]]:
return function(self, data)
return _Tag
return _leaf
[docs]
def argument(
name: str,
default: Optional[Any] = None,
required: bool = False,
choices: Optional[Union[Set[str], Dict[str, Any]]] = None,
type: Optional[Type[int]] = None,
nonzero: bool = False,
) -> Callable[[Type[Tag]], Type[Tag]]:
"""
Add an argument to the tag.
:param name: the argument's name
:param default: the argument's default value
:param required: whether the argument is required
:param choices: the argument's allowed values
:param type: the argument's allowed type
:param nonzero: whether the argument can be 0 (if ``type=int``)
"""
def _argument(cls: Type[Tag]) -> Type[Tag]:
cls.argument_names.add(name)
if default is not None:
cls.argument_defaults[name] = default
if required:
cls.argument_required.add(name)
if choices:
cls.argument_validators[name] = lambda v: v in choices
cls.argument_validation_messages[name] = "The value must be one of: %s." % quote_list(choices)
if type is int:
cls.argument_converters[name] = int
if nonzero:
cls.argument_validators[name] = lambda v: (isinstance(v, int) or v.isdigit()) and int(v) > 0
cls.argument_validation_messages[name] = "The value must be a positive integer."
else:
cls.argument_validators[name] = lambda v: (isinstance(v, int) or v.isdigit()) and int(v) >= 0
cls.argument_validation_messages[name] = "The value must be a non-negative integer."
return cls
return _argument
[docs]
def generate_error_template_tag(message: str) -> Type[TemplateTag]:
"""
Build a :class:`~exporter.tag.TemplateTag` for the error template and set the error ``message``.
"""
@template("error", settings.GDOCS_TEMPLATES["DEFAULT_ERROR_TEMPLATE"], (value_tag,))
def _tag(tag: TemplateTag) -> Dict[str, Any]:
return {"value": message}
return _tag
[docs]
@leaf("value")
def value_tag(tag: LeafTag, data: Dict[str, Any]) -> str:
return data["value"]