"""
Module with generators for producing HTML content.
Generators can be used to create customizable HTML content,
that can be used to test selectors and workflows.
Classes
-------
- `AttributeGenerator` - class for generating HTML attribute strings.
- `TagGenerator` - class for generating HTML tag strings.
"""
from __future__ import annotations
from collections.abc import Iterable
from typing import Optional, Union
import soupsavvy.exceptions as exc
from soupsavvy.testing.generators import namespace
from soupsavvy.testing.generators.base import BaseGenerator
from soupsavvy.testing.generators.templates.base import BaseTemplate
from soupsavvy.testing.generators.templates.config import (
DEFAULT_TEXT_TEMPLATE,
DEFAULT_VALUE_TEMPLATE,
)
from soupsavvy.testing.generators.templates.templates import ConstantTemplate
# types for generators
TemplateType = Optional[Union[BaseTemplate, str]]
AttributeType = Union["AttributeGenerator", str, tuple[str, TemplateType]]
ChildType = Union["TagGenerator", str]
AttributeList = Iterable[AttributeType]
ChildList = Iterable[ChildType]
AttributeGeneratorInitExceptions = (
TypeError,
exc.InvalidTemplateException,
exc.EmptyNameException,
)
def _get_template_type(
value: TemplateType, param: str, default: BaseTemplate
) -> BaseTemplate:
"""
Get the template type from the input value.
Parameters
----------
value : TemplateType
The value to get the template type from.
param : str
The name of the parameter.
default : BaseTemplate
The default template to use if the value is None.
Returns
-------
BaseTemplate
The template instance.
Raises
------
InvalidTemplateException
If the value is not a string, BaseTemplate or None.
"""
if value is None:
return default
elif isinstance(value, str):
return ConstantTemplate(value)
elif not isinstance(value, BaseTemplate):
raise exc.InvalidTemplateException(
f"{param} must be a string or a BaseTemplate, not {type(value)}"
)
return value
[docs]
class AttributeGenerator(BaseGenerator):
"""
Class for generating HTML attribute strings.
AttributeGenerator allows to generate empty HTML attribute with specific name:
Example
--------
>>> gen = AttributeGenerator("class")
... gen.generate()
'class=""'
Attribute can also be generated with specified constant value:
Example
--------
>>> gen = AttributeGenerator("class", value="container")
... gen.generate()
'class="container"'
Value can be passed as BaseTemplate instance as well:
Example
--------
>>> from soupsavvy.testing.generators import RandomTemplate
... template = RandomTemplate(length=4, seed=42)
... gen = AttributeGenerator("id", value=template)
... gen.generate()
'id="Nbrn"'
For more information on available Templates, how to use them
and customize for your needs, see the documentation.
See also
--------
- `soupsavvy.testing.generators.templates` package
- `soupsavvy.testing.TagGenerator` class
"""
[docs]
def __init__(self, name: str, value: TemplateType = None) -> None:
"""
Initializes the AttributeGenerator.
Parameters
----------
name : str
The name of the attribute.
value : TemplateType, optional
The value of the attribute. Defaults to None.
"""
self._check_name(name)
self.name = name
self.value = _get_template_type(
value,
param="name",
default=DEFAULT_VALUE_TEMPLATE,
)
def _check_name(self, name: str) -> None:
"""
Check if the input name is a valid HTML attribute name.
Parameters
----------
name : str
The name to check.
Raises
------
TypeError
If the name is not a string.
EmptyNameException
If the name is an empty string.
"""
if not isinstance(name, str):
raise TypeError(f"'name' parameter must be a string, not {type(name)}")
if not name:
raise exc.EmptyNameException(
"Empty string is not allowed html attribute name, "
"not-empty string must be provided for 'name' parameter."
)
[docs]
def generate(self) -> str:
"""
Generates the HTML attribute string.
Returns
-------
str
The generated HTML attribute string.
"""
value = self.value.generate()
return f'{self.name}="{value}"'
[docs]
class TagGenerator(BaseGenerator):
"""
Class for generating HTML tag strings.
TagGenerator allows to generate empty HTML tag with specific name:
Example
--------
>>> gen = TagGenerator("div")
... gen.generate()
'<div></div>'
Tag can also be generated with specified attributes:
Example
--------
>>> gen = TagGenerator("div", attrs=[("class", "container")])
... gen.generate()
'<div class="container"></div>'
Attributes can be passed as an iterable of of mixed types:
- AttributeGenerator - instance of AttributeGenerator class
- str - attribute name, value would be a default Template (empty string)
- tuple[str, TemplateType] - attribute name and literal value or Template
Example
--------
>>> gen = TagGenerator(
... name="a",
... attrs=[
... ("id", "link"),
... AttributeGenerator("href", "/endpoint"),
... "class",
... ],
... )
... gen.generate()
'<div id="link" href="/endpoint", class=""></div>'
Similarly, children can be passed as an iterable of mixed types:
- TagGenerator - instance of TagGenerator class
- str - tag name, children tags would be empty
Example
--------
>>> gen = TagGenerator(
... name="div",
... children=[
... "a",
... TagGenerator("span", attrs=[("class", "container")],
... ],
... )
... gen.generate()
'<div><a></a><span class="container"></span></div>'
Text of the tag can be passed as a string or a Template:
Example
--------
>>> gen = TagGenerator("p", text="Hello, World!")
... gen.generate()
'<p>Hello, World!</p>'
Example
--------
>>> from soupsavvy.testing.generators import RandomTemplate
... template = RandomTemplate(length=4, seed=42)
... gen = TagGenerator("p", text=template)
... gen.generate()
'<p>Nbrn</p>'
For more information on available Templates, how to use them
and customize for your needs, see the documentation.
Void tags like `<img>`, `<br>`, `<hr>` etc. can be generated as well
and are automatically closed:
Example
--------
>>> gen = TagGenerator("img", attrs=[("src", "/path/to/image.jpg")])
... gen.generate()
'<img src="/path/to/image.jpg"/>'
No children are allowed for void tags, and an error will be raised.
See also
--------
- `soupsavvy.testing.generators.templates` package
- `soupsavvy.testing.AttributeGenerator` class
"""
[docs]
def __init__(
self,
name: str,
attrs: AttributeList = (),
children: ChildList = (),
text: TemplateType = None,
) -> None:
"""
Initialize the `TagGenerator`.
Parameters
----------
name : str
The name of the HTML tag.
attrs : AttributeList, optional
The attributes of the tag. Defaults to empty tuple.
children : ChildList, optional
The children of the tag. Defaults to empty tuple.
text : TemplateType, optional
The text content of the tag.
Defaults to None, which generates empty string.
"""
if isinstance(attrs, str):
raise TypeError("'attrs' must be an iterable of attributes, not a string")
self._void = name in namespace.VOID_TAGS
if self._void and children:
raise exc.VoidTagWithChildrenException(
f"'{name}' is a void tag and cannot have children"
)
self._check_name(name)
self.name = name
self.text = _get_template_type(
text,
param="text",
default=DEFAULT_TEXT_TEMPLATE,
)
self.attributes = self._process_attributes(attrs)
self.children = [
child if isinstance(child, TagGenerator) else TagGenerator(child)
for child in children
]
def _check_name(self, name: str) -> None:
"""
Check if the input name is a valid HTML tag name.
Parameters
----------
name : str
The name to check.
Raises
------
TypeError
If the name is not a string.
EmptyNameException
If the name is an empty string.
"""
if not isinstance(name, str):
raise TypeError(f"'name' parameter must be a string, not {type(name)}")
if not name:
raise exc.EmptyNameException(
"Empty string is not allowed html tag name, "
"not-empty string must be provided for 'name' parameter."
)
def _process_attributes(
self, attributes: AttributeList
) -> list[AttributeGenerator]:
"""
Process the input attributes of the tag.
Parameters
----------
attributes : AttributeList
The attributes to process.
Returns
-------
list[AttributeGenerator]
The processed instances of AttributeGenerators.
Raises
------
AttributeParsingError
If the input attributes could not be parsed into AttributeGenerators.
"""
# dict approach for uniqueness check
attr_dict: dict[str, AttributeGenerator] = {}
for attr in attributes:
if isinstance(attr, str):
attr = (attr, None)
if not isinstance(attr, AttributeGenerator):
try:
attr = AttributeGenerator(*attr)
except AttributeGeneratorInitExceptions as e:
raise exc.AttributeParsingError(
f"Attribute {attr} could not be parsed into AttributeGenerator "
"due to following error:"
) from e
attr_name = attr.name
if attr_name in attr_dict:
raise exc.NotUniqueAttributesException(
f"Input attribute {attr_name} is not unique."
)
attr_dict[attr_name] = attr
return list(attr_dict.values())
[docs]
def generate(self) -> str:
"""
Generates the HTML tag string.
Returns
-------
str
The generated HTML tag string.
"""
attrs = " ".join(attr.generate() for attr in self.attributes)
sep = " " if attrs else ""
children = "".join(child.generate() for child in self.children)
tag_content = f"{self.name}{sep}{attrs}"
if self._void:
return f"<{tag_content}/>"
text = self.text.generate()
return f"<{tag_content}>{children}{text}</{self.name}>"