From d815b10da437cf1442421f1cd15b6ada658382f4 Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Wed, 5 Feb 2025 23:29:59 +1100 Subject: [PATCH 01/23] Create Options class --- pyhtml/__render_options.py | 72 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 pyhtml/__render_options.py diff --git a/pyhtml/__render_options.py b/pyhtml/__render_options.py new file mode 100644 index 0000000..7d99f84 --- /dev/null +++ b/pyhtml/__render_options.py @@ -0,0 +1,72 @@ +""" +# PyHTML / Render Options + +Definition for the `Options` object, used to control rendering. +""" + +from dataclasses import asdict, dataclass +from typing import Optional + +# While it could be cleaner (and far less-repetitive) to use a TypedDict and +# declare the partial options class as per +# https://discuss.python.org/t/introduce-partial-for-typeddict/45176/4 +# I elected not to do this, as by using a dataclass, the type is much more +# explicit, meaning that users won't encounter very confusing type-checker +# errors if they inadvertently pass a non-Options-shaped `dict` to a tag +# constructor. +# By using a `dataclass`, it is also much easier to perform `isinstance` +# checking on the object, which simplifies the implementation significantly. +# The only down-side is the duplicated definitions and copy-pasted +# documentation. + + +@dataclass(kw_only=True, frozen=True) +class FullOptions: + indent: str + """String to add to indentation for non-inline child elements""" + spacing: str + """String to use for spacing between child elements""" + + def union(self, other: "Options | FullOptions") -> "FullOptions": + """ + Union this set of options with the other options, returning a new + `Options` object as the result. + + Any non-`None` options in `other` will overwrite the original values. + """ + values = asdict(self) + for field in values: + if (other_value := getattr(other, field)) is not None: + values[field] = other_value + + return FullOptions(**values) + + +@dataclass(kw_only=True, frozen=True) +class Options: + """ + PyHTML rendering options. + + * `indent` (`str`): string to add to indentation for non-inline child + elements. For example, to indent using a tab, you could use `'\\t'`. + Defaults to 2 spaces `' '`. + * `spacing` (`str`): string to use for spacing between child elements. When + this is set to `'\\n'`, each child element will be placed on its own + line, and indentation will be applied. Otherwise, each child element will + be separated using the given value. + """ + + indent: Optional[str] = None + """String to add to indentation for non-inline child elements""" + spacing: Optional[str] = None + """String to use for spacing between child elements""" + + @staticmethod + def default() -> FullOptions: + """ + Returns PyHTML's default rendering options. + """ + return FullOptions( + indent=" ", + spacing="\n", + ) From 4b495d53812fadfec4b9ec4f9d6662e47683952f Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Wed, 5 Feb 2025 23:39:10 +1100 Subject: [PATCH 02/23] Require Python 3.10 to support kw_only dataclasses --- .github/workflows/mkdocs-dryrun.yml | 2 +- .github/workflows/mkdocs.yml | 2 +- .github/workflows/publish.yml | 2 +- .github/workflows/python-app.yml | 12 ++++++------ pyproject.toml | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/mkdocs-dryrun.yml b/.github/workflows/mkdocs-dryrun.yml index 674d6bf..abdac32 100644 --- a/.github/workflows/mkdocs-dryrun.yml +++ b/.github/workflows/mkdocs-dryrun.yml @@ -24,7 +24,7 @@ jobs: id: python uses: actions/setup-python@v5 with: - python-version: '3.9' + python-version: '3.10' # Install dependencies using Poetry - uses: Gr1N/setup-poetry@v9 diff --git a/.github/workflows/mkdocs.yml b/.github/workflows/mkdocs.yml index ccba4b4..0e04867 100644 --- a/.github/workflows/mkdocs.yml +++ b/.github/workflows/mkdocs.yml @@ -36,7 +36,7 @@ jobs: id: python uses: actions/setup-python@v5 with: - python-version: '3.9' + python-version: '3.10' # Install dependencies using Poetry - uses: Gr1N/setup-poetry@v9 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1ae96e5..b74c796 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,7 +14,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: 3.9 + python-version: '3.10' # Install dependencies using Poetry - uses: Gr1N/setup-poetry@v9 diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index bec1d24..49574b5 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -26,10 +26,10 @@ jobs: - uses: actions/checkout@v4 with: submodules: 'recursive' - - name: Set up Python 3.9 + - name: Set up Python uses: actions/setup-python@v5 with: - python-version: 3.9 + python-version: '3.10' - uses: Gr1N/setup-poetry@v9 - uses: actions/cache@v4 with: @@ -60,10 +60,10 @@ jobs: - uses: actions/checkout@v4 with: submodules: 'recursive' - - name: Set up Python 3.9 + - name: Set up Python uses: actions/setup-python@v5 with: - python-version: 3.9 + python-version: '3.10' - uses: Gr1N/setup-poetry@v9 - uses: actions/cache@v4 with: @@ -81,10 +81,10 @@ jobs: - uses: actions/checkout@v4 with: submodules: 'recursive' - - name: Set up Python 3.9 + - name: Set up Python uses: actions/setup-python@v5 with: - python-version: 3.9 + python-version: '3.10' - uses: Gr1N/setup-poetry@v9 - uses: actions/cache@v4 with: diff --git a/pyproject.toml b/pyproject.toml index 074afd1..7ec976b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,7 +79,7 @@ exclude_also = [ ] [tool.poetry.dependencies] -python = "^3.9" +python = "^3.10" [tool.poetry.group.dev.dependencies] pytest = ">=7.4.2,<9.0.0" From efa006b3f36ceedc7c3e91e6f4f308398b378e59 Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Wed, 5 Feb 2025 23:40:33 +1100 Subject: [PATCH 03/23] Poetry lock --- poetry.lock | 61 +++-------------------------------------------------- 1 file changed, 3 insertions(+), 58 deletions(-) diff --git a/poetry.lock b/poetry.lock index 810b4c9..98ad1b2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -333,7 +333,6 @@ files = [ [package.dependencies] blinker = ">=1.9" click = ">=8.1.3" -importlib-metadata = {version = ">=3.6", markers = "python_version < \"3.10\""} itsdangerous = ">=2.2" Jinja2 = ">=3.1.2" Werkzeug = ">=3.1" @@ -390,31 +389,6 @@ files = [ [package.extras] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] -[[package]] -name = "importlib-metadata" -version = "8.5.0" -description = "Read metadata from Python packages" -optional = false -python-versions = ">=3.8" -groups = ["dev", "docs"] -markers = "python_version < \"3.10\"" -files = [ - {file = "importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b"}, - {file = "importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7"}, -] - -[package.dependencies] -zipp = ">=3.20" - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -perf = ["ipython"] -test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] -type = ["pytest-mypy"] - [[package]] name = "iniconfig" version = "2.0.0" @@ -506,9 +480,6 @@ files = [ {file = "markdown-3.7.tar.gz", hash = "sha256:2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2"}, ] -[package.dependencies] -importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} - [package.extras] docs = ["mdx-gh-links (>=0.2)", "mkdocs (>=1.5)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"] testing = ["coverage", "pyyaml"] @@ -611,7 +582,6 @@ files = [ click = ">=7.0" colorama = {version = ">=0.4", markers = "platform_system == \"Windows\""} ghp-import = ">=1.0" -importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} jinja2 = ">=2.11.1" markdown = ">=3.3.6" markupsafe = ">=2.0.1" @@ -674,7 +644,6 @@ files = [ ] [package.dependencies] -importlib-metadata = {version = ">=4.3", markers = "python_version < \"3.10\""} mergedeep = ">=1.3.4" platformdirs = ">=2.2.0" pyyaml = ">=5.1" @@ -750,7 +719,6 @@ files = [ [package.dependencies] click = ">=7.0" -importlib-metadata = {version = ">=4.6", markers = "python_version < \"3.10\""} Jinja2 = ">=2.11.1" Markdown = ">=3.6" MarkupSafe = ">=1.1" @@ -759,7 +727,6 @@ mkdocs-autorefs = ">=1.2" mkdocstrings-python = {version = ">=0.5.2", optional = true, markers = "extra == \"python\""} platformdirs = ">=2.2" pymdown-extensions = ">=6.3" -typing-extensions = {version = ">=4.1", markers = "python_version < \"3.10\""} [package.extras] crystal = ["mkdocstrings-crystal (>=0.3.4)"] @@ -1482,12 +1449,11 @@ version = "4.12.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" -groups = ["dev", "docs"] +groups = ["dev"] files = [ {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] -markers = {docs = "python_version < \"3.10\""} [[package]] name = "urllib3" @@ -1583,28 +1549,7 @@ MarkupSafe = ">=2.1.1" [package.extras] watchdog = ["watchdog (>=2.3)"] -[[package]] -name = "zipp" -version = "3.20.2" -description = "Backport of pathlib-compatible object wrapper for zip files" -optional = false -python-versions = ">=3.8" -groups = ["dev", "docs"] -markers = "python_version < \"3.10\"" -files = [ - {file = "zipp-3.20.2-py3-none-any.whl", hash = "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350"}, - {file = "zipp-3.20.2.tar.gz", hash = "sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29"}, -] - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] -type = ["pytest-mypy"] - [metadata] lock-version = "2.1" -python-versions = "^3.9" -content-hash = "0ce8535bfa04dc126c07fc5665aa5c844cff464d4ba1c3aee0b55f543ee8f4cf" +python-versions = "^3.10" +content-hash = "ef818b5263f5c19eb619288f9feb29283b64fc669fcdf6bc43a90bdfeed7bc7a" From d990063a708a02ff8e58d3d48a08836fbe889c99 Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Wed, 12 Feb 2025 17:21:57 +1100 Subject: [PATCH 04/23] Use render options during rendering --- meta/generate_tag_defs.py | 6 +- pyhtml/__render_options.py | 14 ++++ pyhtml/__tag_base.py | 104 +++++++++++++++++++--------- pyhtml/__tags/comment.py | 15 ++-- pyhtml/__tags/dangerous_raw_html.py | 9 ++- pyhtml/__types.py | 8 ++- pyhtml/__util.py | 93 ++++++++++++++----------- 7 files changed, 163 insertions(+), 86 deletions(-) diff --git a/meta/generate_tag_defs.py b/meta/generate_tag_defs.py index 19d0606..925438f 100644 --- a/meta/generate_tag_defs.py +++ b/meta/generate_tag_defs.py @@ -70,8 +70,10 @@ def generate_tag_class(output: TextIO, tag: TagInfo): attr_args = "\n".join(attr_args_gen).strip() attr_unions = "\n".join(attr_unions_gen).strip() - attr_docs_outer = "\n".join(increase_indent(attr_docs_gen, 4)).strip() - attr_docs_inner = "\n".join(increase_indent(attr_docs_gen, 8)).strip() + attr_docs_outer = "\n".join(increase_indent(attr_docs_gen, " ")).strip() + attr_docs_inner = "\n".join( + increase_indent(attr_docs_gen, " ") + ).strip() # Determine whether the class should mandate keyword-only args # If there are no named attributes, we set it to '' to avoid a syntax error diff --git a/pyhtml/__render_options.py b/pyhtml/__render_options.py index 7d99f84..23a9335 100644 --- a/pyhtml/__render_options.py +++ b/pyhtml/__render_options.py @@ -70,3 +70,17 @@ def default() -> FullOptions: indent=" ", spacing="\n", ) + + def union(self, other: "Options | FullOptions") -> "Options": + """ + Union this set of options with the other options, returning a new + `Options` object as the result. + + Any non-`None` options in `other` will overwrite the original values. + """ + values = asdict(self) + for field in values: + if (other_value := getattr(other, field)) is not None: + values[field] = other_value + + return Options(**values) diff --git a/pyhtml/__tag_base.py b/pyhtml/__tag_base.py index a61e07b..ef116d5 100644 --- a/pyhtml/__tag_base.py +++ b/pyhtml/__tag_base.py @@ -3,18 +3,21 @@ Tag base class, including rendering logic """ + from typing import Optional, TypeVar from . import __util as util +from .__render_options import FullOptions, Options from .__types import AttributeType, ChildrenType -SelfType = TypeVar('SelfType', bound='Tag') +SelfType = TypeVar("SelfType", bound="Tag") class Tag: """ Base tag class """ + def __init__( self, *children: ChildrenType, @@ -23,26 +26,32 @@ def __init__( """ Create a new tag instance """ - self.children = util.flatten_list(list(children)) + flattened, options = util.flatten_children(list(children)) + self.children = flattened """Children of this tag""" self.attributes = util.filter_attributes(attributes) """Attributes of this tag""" + self.options = options + """Render options specified for this element""" + def __call__( self: SelfType, *children: ChildrenType, **attributes: AttributeType, - ) -> 'SelfType': + ) -> "SelfType": """ Create a new tag instance derived from this tag. Its children and attributes are based on this original tag, but with additional children appended and additional attributes unioned. """ - new_children = self.children + util.flatten_list(list(children)) + flattened, options = util.flatten_children(list(children)) + new_children = self.children + flattened new_attributes = util.dict_union(self.attributes, attributes) + new_options = self.options.union(options) - return self.__class__(*new_children, **new_attributes) + return self.__class__(*new_children, new_options, **new_attributes) def __iter__(self) -> None: """ @@ -59,7 +68,7 @@ def _get_tag_name(self) -> str: """ Returns the name of the tag """ - return type(self).__name__.removesuffix('_') + return type(self).__name__.removesuffix("_") def _get_default_attributes( self, @@ -94,18 +103,35 @@ def _escape_children(self) -> bool: """ return True - def _render(self, indent: int) -> list[str]: + def _render(self, indent: str, options: FullOptions) -> list[str]: """ Renders tag and its children to a list of strings where each string is - a single line of output + a single line of output. + + Parameters + ---------- + indent : str + string to use for indentation + options : FullOptions + rendering options + + Returns + ------- + list[str] + list of lines of output """ - attributes = util.filter_attributes(util.dict_union( - self._get_default_attributes(self.attributes), - self.attributes, - )) + # Determine what the options for this element are + options = options.union(self.options) + + attributes = util.filter_attributes( + util.dict_union( + self._get_default_attributes(self.attributes), + self.attributes, + ) + ) # Tag and attributes - opening = f"{' ' * indent}<{self._get_tag_name()}" + opening = f"{indent}<{self._get_tag_name()}" # Add pre-content if (pre := self._get_tag_pre_content()) is not None: @@ -126,11 +152,12 @@ def _render(self, indent: int) -> list[str]: util.render_children( self.children, self._escape_children(), - indent + 2, + indent + options.indent, + options, ) ) # Closing tag - out.append(f"{' ' * indent}") + out.append(f"{indent}") return out @@ -138,7 +165,7 @@ def render(self) -> str: """ Render this tag and its contents to a string """ - return '\n'.join(self._render(0)) + return "\n".join(self._render("", Options.default())) def __str__(self) -> str: return self.render() @@ -151,32 +178,36 @@ class SelfClosingTag(Tag): """ Self-closing tags don't contain child elements """ + def __init__(self, **attributes: AttributeType) -> None: # Self-closing tags don't allow children super().__init__(**attributes) - def _render(self, indent: int) -> list[str]: + def _render(self, indent: str, options: FullOptions) -> list[str]: """ Renders tag and its children to a list of strings where each string is a single line of output """ - attributes = util.filter_attributes(util.dict_union( - self._get_default_attributes(self.attributes), - self.attributes, - )) + attributes = util.filter_attributes( + util.dict_union( + self._get_default_attributes(self.attributes), + self.attributes, + ) + ) if len(attributes): return [ - f"{' ' * indent}<{self._get_tag_name()} " + f"{indent}<{self._get_tag_name()} " f"{util.render_tag_attributes(attributes)}/>" ] else: - return [f"{' ' * indent}<{self._get_tag_name()}/>"] + return [f"{indent}<{self._get_tag_name()}/>"] class WhitespaceSensitiveTag(Tag): """ Whitespace-sensitive tags are tags where whitespace needs to be respected. """ + def __init__( self, *children: ChildrenType, @@ -193,25 +224,30 @@ def __call__( # type: ignore attributes |= {} return super().__call__(*children, **attributes) - def _render(self, indent: int) -> list[str]: - attributes = util.filter_attributes(util.dict_union( - self._get_default_attributes(self.attributes), - self.attributes, - )) + def _render(self, indent: str, options: FullOptions) -> list[str]: + attributes = util.filter_attributes( + util.dict_union( + self._get_default_attributes(self.attributes), + self.attributes, + ) + ) # Tag and attributes - output = f"{' ' * indent}<{self._get_tag_name()}" + output = f"{indent}<{self._get_tag_name()}" if len(attributes): output += f" {util.render_tag_attributes(attributes)}>" else: output += ">" - output += '\n'.join(util.render_children( - self.children, - self._escape_children(), - 0, - )) + output += "\n".join( + util.render_children( + self.children, + self._escape_children(), + "", + options, + ) + ) output += f"" return output.splitlines() diff --git a/pyhtml/__tags/comment.py b/pyhtml/__tags/comment.py index 04e8a5b..fcc8056 100644 --- a/pyhtml/__tags/comment.py +++ b/pyhtml/__tags/comment.py @@ -3,7 +3,9 @@ Definition for the comment tag. """ + from .. import __util as util +from ..__render_options import FullOptions from ..__tag_base import Tag @@ -19,6 +21,7 @@ class Comment(Tag): Note that this does not render as a `` tag """ + def __init__(self, text: str) -> None: """ An HTML comment. @@ -35,24 +38,24 @@ def __init__(self, text: str) -> None: super().__init__() def __call__(self, *args, **kwargs): - raise TypeError('Comment tags are not callable') + raise TypeError("Comment tags are not callable") def _get_tag_name(self) -> str: # Ignore coverage since this is only implemented to satisfy inheritance # and is never used since we override _render - return '!--' # pragma: no cover + return "!--" # pragma: no cover - def _render(self, indent: int) -> list[str]: + def _render(self, indent: str, options: FullOptions) -> list[str]: """ Override of render, to render comments """ return util.increase_indent( - [''], + + ["-->"], indent, ) diff --git a/pyhtml/__tags/dangerous_raw_html.py b/pyhtml/__tags/dangerous_raw_html.py index fe39b1c..95aee61 100644 --- a/pyhtml/__tags/dangerous_raw_html.py +++ b/pyhtml/__tags/dangerous_raw_html.py @@ -3,6 +3,8 @@ Definition for the DangerousRawHtml tag. """ + +from ..__render_options import FullOptions from ..__tag_base import Tag from ..__util import increase_indent @@ -20,6 +22,7 @@ class DangerousRawHtml(Tag): Do not use this unless absolutely necessary. """ + def __init__(self, text: str) -> None: """ Raw HTML as a string. This is embedded directly within the rendered @@ -38,12 +41,12 @@ def __init__(self, text: str) -> None: super().__init__() def __call__(self, *args, **kwargs): - raise TypeError('DangerousRawHtml tags are not callable') + raise TypeError("DangerousRawHtml tags are not callable") def _get_tag_name(self) -> str: # Ignore coverage since this is only implemented to satisfy inheritance # and is never used since we override _render - return '!!!DANGEROUS RAW HTML!!!' # pragma: no cover + return "!!!DANGEROUS RAW HTML!!!" # pragma: no cover - def _render(self, indent: int) -> list[str]: + def _render(self, indent: str, options: FullOptions) -> list[str]: return increase_indent(self.html_data.splitlines(), indent) diff --git a/pyhtml/__types.py b/pyhtml/__types.py index 96e26a0..df460cd 100644 --- a/pyhtml/__types.py +++ b/pyhtml/__types.py @@ -3,10 +3,12 @@ Type definitions """ + from collections.abc import Generator, Sequence from typing import TYPE_CHECKING, Union if TYPE_CHECKING: + from .__render_options import Options from .__tag_base import Tag @@ -21,7 +23,7 @@ """ -ChildElementType = Union['Tag', type['Tag'], str] +ChildElementType = Union["Tag", type["Tag"], str] """ Objects that are valid as a child element of an HTML element. @@ -33,9 +35,10 @@ ChildrenType = Union[ ChildElementType, Sequence[ChildElementType], - 'Generator[ChildElementType, None, None]', # TODO: Would an `Any` type for the generator return be better, even though # it would be discarded? + "Generator[ChildElementType, None, None]", + "Options", ] """ Objects that are valid when passed to a `Tag` for use as children. @@ -43,4 +46,5 @@ * `ChildElementType`: a singular element * `list[ChildElementType]`: a list of elements * `GeneratorType[ChildElementType, None, None]`: a generator of elements +* `Options`: PyHTML render options. """ diff --git a/pyhtml/__util.py b/pyhtml/__util.py index 47bf301..4a48b24 100644 --- a/pyhtml/__util.py +++ b/pyhtml/__util.py @@ -3,26 +3,23 @@ Random helpful functions used elsewhere """ + from collections.abc import Generator, Sequence from typing import Any, TypeVar +from .__render_options import FullOptions, Options from .__types import ChildElementType, ChildrenType -T = TypeVar('T') -K = TypeVar('K') -V = TypeVar('V') +T = TypeVar("T") +K = TypeVar("K") +V = TypeVar("V") -def increase_indent(text: list[str], amount: int) -> list[str]: +def increase_indent(text: list[str], indent: str) -> list[str]: """ Increase the indentation of all lines in a string list """ - prefix = amount * ' ' - - return list(map( - lambda line: prefix + line, - text - )) + return list(map(lambda line: indent + line, text)) def escape_string(text: str) -> str: @@ -32,11 +29,11 @@ def escape_string(text: str) -> str: """ replacements = { # & needs to be replaced first or we break all other options - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''', + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", } for k, v in replacements.items(): text = text.replace(k, v) @@ -54,10 +51,7 @@ def escape_attribute(attr_name: str) -> str: * `_` (underscore) to `-` (hyphen), so that kwargs can be used effectively """ - return attr_name \ - .removeprefix('_') \ - .removesuffix('_') \ - .replace('_', '-') + return attr_name.removeprefix("_").removesuffix("_").replace("_", "-") def render_tag_attributes(attributes: dict[str, Any]) -> str: @@ -76,12 +70,14 @@ def render_tag_attributes(attributes: dict[str, Any]) -> str: 'src="https://example.com/test.jpg" alt="A test image"' ``` """ - return ' '.join([ - f'{escape_attribute(attr)}="{escape_string(str(val))}"' - if val is not True - else escape_attribute(attr) - for attr, val in attributes.items() - ]) + return " ".join( + [ + f'{escape_attribute(attr)}="{escape_string(str(val))}"' + if val is not True + else escape_attribute(attr) + for attr, val in attributes.items() + ] + ) def filter_attributes(attributes: dict[str, Any]) -> dict[str, Any]: @@ -90,26 +86,26 @@ def filter_attributes(attributes: dict[str, Any]) -> dict[str, Any]: rendered. """ return { - k: v - for k, v in attributes.items() - if v is not None and v is not False + k: v for k, v in attributes.items() if v is not None and v is not False } def render_inline_element( ele: ChildElementType, escape_strings: bool, - indent: int, + indent: str, + options: FullOptions, ) -> list[str]: """ Render an element inline """ from .__tag_base import Tag + if isinstance(ele, Tag): - return ele._render(indent) + return ele._render(indent, options) elif isinstance(ele, type) and issubclass(ele, Tag): e = ele() - return e._render(indent) + return e._render(indent, options) else: # Remove newlines from strings when inline rendering if escape_strings: @@ -121,7 +117,8 @@ def render_inline_element( def render_children( children: list[ChildElementType], escape_strings: bool, - indent: int, + indent: str, + options: FullOptions, ) -> list[str]: """ Render child elements of tags. @@ -130,19 +127,35 @@ def render_children( """ rendered = [] for ele in children: - rendered.extend(render_inline_element(ele, escape_strings, indent)) + rendered.extend( + render_inline_element(ele, escape_strings, indent, options) + ) return rendered -def flatten_list(the_list: list[ChildrenType]) -> list[ChildElementType]: +def flatten_children( + the_list: list[ChildrenType], +) -> tuple[list[ChildElementType], Options]: """ - Flatten a list by taking any list elements and inserting their items - individually. Note that other iterables (such as str and tuple) are not - flattened. + Flatten the given list of child elements, and extract the given render + options. - FIXME: Currently doesn't support lists of lists + Note that other iterables (such as str and tuple) are not flattened. Lists + of lists are not supported. + + Parameters + ---------- + the_list : list[ChildrenType] + list of children elements + + Returns + ------- + (result, options) - tuple[list[ChildElementType], Options] + * `result` is the flattened list + * `options` is the `Options` object containing the render options """ result: list[ChildElementType] = [] + options = Options() for item in the_list: if isinstance(item, (list, Generator)): result.extend(item) @@ -150,9 +163,11 @@ def flatten_list(the_list: list[ChildrenType]) -> list[ChildElementType]: result.append(item) elif isinstance(item, Sequence): result.extend(item) + elif isinstance(item, Options): + options = options.union(item) else: result.append(item) - return result + return result, options def dict_union(base: dict[K, V], additions: dict[K, V]) -> dict[K, V]: From 22583405f5ffcbc24a3ca5ce2015e16023456d60 Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Wed, 12 Feb 2025 17:36:16 +1100 Subject: [PATCH 05/23] Regenerate tags and remove portal tag --- meta/templates/class_attrs_SelfClosingTag.py | 4 +- meta/templates/main.py | 1 + pyhtml/__init__.py | 2 - pyhtml/__tags/__init__.py | 2 - pyhtml/__tags/generated.py | 1513 +++++++++--------- pyhtml/__tags/input_tag.py | 252 ++- 6 files changed, 874 insertions(+), 900 deletions(-) diff --git a/meta/templates/class_attrs_SelfClosingTag.py b/meta/templates/class_attrs_SelfClosingTag.py index 47df9ad..329f636 100644 --- a/meta/templates/class_attrs_SelfClosingTag.py +++ b/meta/templates/class_attrs_SelfClosingTag.py @@ -8,7 +8,7 @@ class {name}({base}): """ def __init__( self, - {kw_only} + *options: Options, {attr_args} **attributes: AttributeType, ) -> None: @@ -26,7 +26,7 @@ def __init__( def __call__( # type: ignore self, - {kw_only} + *options: Options, {attr_args} **attributes: AttributeType, ): diff --git a/meta/templates/main.py b/meta/templates/main.py index 3b80466..5524376 100644 --- a/meta/templates/main.py +++ b/meta/templates/main.py @@ -10,3 +10,4 @@ from typing import Any, Optional, Union, Literal from ..__tag_base import Tag, SelfClosingTag, WhitespaceSensitiveTag from ..__types import AttributeType, ChildrenType +from ..__render_options import Options diff --git a/pyhtml/__init__.py b/pyhtml/__init__.py index c32e64c..7cfd070 100644 --- a/pyhtml/__init__.py +++ b/pyhtml/__init__.py @@ -384,7 +384,6 @@ 'iframe', 'object', 'picture', - 'portal', 'source', 'canvas', 'noscript', @@ -503,7 +502,6 @@ iframe, object, picture, - portal, source, canvas, noscript, diff --git a/pyhtml/__tags/__init__.py b/pyhtml/__tags/__init__.py index 5037f8b..595fd11 100644 --- a/pyhtml/__tags/__init__.py +++ b/pyhtml/__tags/__init__.py @@ -97,7 +97,6 @@ 'iframe', 'object', 'picture', - 'portal', 'source', 'canvas', 'noscript', @@ -210,7 +209,6 @@ output, p, picture, - portal, pre, progress, q, diff --git a/pyhtml/__tags/generated.py b/pyhtml/__tags/generated.py index 0256660..3a68ea3 100644 --- a/pyhtml/__tags/generated.py +++ b/pyhtml/__tags/generated.py @@ -7,11 +7,10 @@ https://creativecommons.org/licenses/by-sa/2.5/ """ -from typing import Literal, Optional, Union - -from ..__tag_base import SelfClosingTag, Tag, WhitespaceSensitiveTag +from typing import Any, Optional, Union, Literal +from ..__tag_base import Tag, SelfClosingTag, WhitespaceSensitiveTag from ..__types import AttributeType, ChildrenType - +from ..__render_options import Options class html(Tag): """ @@ -76,7 +75,7 @@ class base(SelfClosingTag): """ def __init__( self, - *, + *options: Options, href: AttributeType = None, target: AttributeType = None, **attributes: AttributeType, @@ -97,7 +96,7 @@ def __init__( def __call__( # type: ignore self, - *, + *options: Options, href: AttributeType = None, target: AttributeType = None, **attributes: AttributeType, @@ -124,43 +123,43 @@ class head(Tag): """ Contains machine-readable information (metadata) about the document, like its [title](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/title), [scripts](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script), and [style sheets](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/style). - + [View full documentation](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/head) """ def __init__( self, *children: ChildrenType, - + **attributes: AttributeType, ) -> None: """ Contains machine-readable information (metadata) about the document, like its [title](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/title), [scripts](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script), and [style sheets](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/style). - + [View full documentation](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/head) """ attributes |= { - + } super().__init__(*children, **attributes) def __call__( # type: ignore self, *children: ChildrenType, - + **attributes: AttributeType, ): """ Contains machine-readable information (metadata) about the document, like its [title](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/title), [scripts](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script), and [style sheets](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/style). - + [View full documentation](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/head) """ attributes |= { - + } return super().__call__(*children, **attributes) @@ -179,7 +178,7 @@ class link(SelfClosingTag): """ def __init__( self, - *, + *options: Options, href: Optional[str] = None, rel: Optional[str] = None, **attributes: AttributeType, @@ -200,7 +199,7 @@ def __init__( def __call__( # type: ignore self, - *, + *options: Options, href: Optional[str] = None, rel: Optional[str] = None, **attributes: AttributeType, @@ -227,43 +226,43 @@ class meta(Tag): """ Represents [metadata](https://developer.mozilla.org/en-US/docs/Glossary/Metadata) that cannot be represented by other HTML meta-related elements, like [](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base), [](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link), [', - ]) + assert str(script(type="blah/blah")("// Some JS")) == "\n".join( + [ + '", + ] + ) def test_call_adds_mixed_attrs_children_script_2(): """Calling a tag adds more properties, using a script tag""" - assert str( - script("// Some JS")(type="blah/blah") - ) == "\n".join([ - '', - ]) + assert str(script("// Some JS")(type="blah/blah")) == "\n".join( + [ + '", + ] + ) -def test_tags_with_trailing_undercore_render_without(): +def test_tags_with_trailing_underscore_render_without(): """ Some tags have a trailing underscore to avoid name collisions. When rendering to HTML, is this removed? @@ -161,26 +176,28 @@ def test_larger_page(): ), ) - assert str(my_website) == '\n'.join([ - '', - '', - ' ', - ' ', - ' Hello, world!', - ' ', - ' ', - ' ', - ' ', - '

', - ' Hello, world!', - '

', - ' ', - ' This is my amazing website rendered with PyHTML Enhanced!', - ' ', - ' ', - '', - ]) + assert str(my_website) == "\n".join( + [ + "", + "", + " ", + " ", + " Hello, world!", + " ", + ' ', + " ", + " ", + "

", + " Hello, world!", + "

", + " ", + " This is my amazing website rendered with PyHTML Enhanced!", + " ", + " ", + "", + ] + ) def test_format_through_repr(): @@ -197,16 +214,18 @@ def test_flatten_element_lists(): """ doc = body([span("Hello"), span("world")]) - assert str(doc) == "\n".join([ - "", - " ", - " Hello", - " ", - " ", - " world", - " ", - "", - ]) + assert str(doc) == "\n".join( + [ + "", + " ", + " Hello", + " ", + " ", + " world", + " ", + "", + ] + ) def test_flatten_element_generators(): @@ -216,12 +235,14 @@ def test_flatten_element_generators(): """ doc = body(c for c in "hi") - assert str(doc) == "\n".join([ - "", - " h", - " i", - "", - ]) + assert str(doc) == "\n".join( + [ + "", + " h", + " i", + "", + ] + ) def test_flatten_element_other_sequence(): @@ -231,12 +252,14 @@ def test_flatten_element_other_sequence(): """ doc = body(("h", "i")) - assert str(doc) == "\n".join([ - "", - " h", - " i", - "", - ]) + assert str(doc) == "\n".join( + [ + "", + " h", + " i", + "", + ] + ) def test_classes_can_render(): @@ -245,11 +268,13 @@ def test_classes_can_render(): """ doc = body(br) - assert str(doc) == "\n".join([ - "", - "
", - "", - ]) + assert str(doc) == "\n".join( + [ + "", + "
", + "", + ] + ) def test_boolean_tag_attributes_true(): @@ -273,3 +298,24 @@ def test_tag_with_pre_content(): Do tags with defined pre-content render correctly """ assert str(html()) == "\n" + + +def test_whitespace_sensitive_no_content(): + """ + Do whitespace-sensitive tags render properly when they have no content? + """ + assert str(pre()) == "
"
+
+
+def test_whitespace_sensitive_with_content():
+    """
+    Do whitespace-sensitive tags render properly when they have content?
+    """
+    assert str(pre("hi")) == "
hi
" + + +def test_whitespace_sensitive_with_attrs(): + """ + Do whitespace-sensitive tags render properly when they have attributes? + """ + assert str(pre(test="test")("hi")) == '
hi
' diff --git a/tests/code_gen_test.py b/tests/code_gen_test.py index c889d34..43bcdef 100644 --- a/tests/code_gen_test.py +++ b/tests/code_gen_test.py @@ -33,7 +33,7 @@ def test_num_exported_members(): We don't want regenerating the code to produce any more (or fewer) members """ # Just update the number if you're expecting it to change - assert len(all_tags) == 116 + assert len(all_tags) == 117 @pytest.mark.parametrize( diff --git a/tests/escape_test.py b/tests/escape_test.py index 4c07f9b..77b792c 100644 --- a/tests/escape_test.py +++ b/tests/escape_test.py @@ -3,43 +3,44 @@ Tests for escape sequences """ + import keyword import pytest -from pyhtml import body +from pyhtml import body, style replacements = [ - ('&', '&'), - ('<', '<'), - ('>', '>'), - ('"', '"'), - ("'", '''), + ("&", "&"), + ("<", "<"), + (">", ">"), + ('"', """), + ("'", "'"), # ('\n', ' '), ] -@pytest.mark.parametrize( - ('string', 'replacement'), - replacements -) +@pytest.mark.parametrize(("string", "replacement"), replacements) def test_escapes_children(string, replacement): - assert str(body( - f"Hello{string}world", - )) == '\n'.join([ - '', - f' Hello{replacement}world', - '', - ]) + assert str( + body( + f"Hello{string}world", + ) + ) == "\n".join( + [ + "", + f" Hello{replacement}world", + "", + ] + ) -@pytest.mark.parametrize( - ('string', 'replacement'), - replacements -) +@pytest.mark.parametrize(("string", "replacement"), replacements) def test_escapes_attribute_values(string: str, replacement: str): - assert str(body(value=f"hello{string}world")) \ + assert ( + str(body(value=f"hello{string}world")) == f'' + ) def test_attribute_names_escapes_dashes(): @@ -51,7 +52,7 @@ def test_attribute_names_escapes_dashes(): @pytest.mark.parametrize( - 'keyword', + "keyword", # On Python 3.9, __peg_parser__ is a reserved keyword because of an # easter egg (https://stackoverflow.com/q/65486981/6335363) # skip over it because there is no point making it work @@ -62,12 +63,12 @@ def test_attribute_names_escapes_python_keywords_prefix(keyword: str): Since Python keywords cannot be given as kwarg names, we need to use escaped versions (eg _for => for) """ - kwargs = {f"_{keyword}": 'hi'} + kwargs = {f"_{keyword}": "hi"} assert str(body(**kwargs)) == f'' @pytest.mark.parametrize( - 'keyword', + "keyword", # Skip __peg_parser__ in Python 3.9 (see test above) filter(lambda kw: kw != "__peg_parser__", keyword.kwlist), ) @@ -76,5 +77,15 @@ def test_attribute_names_escapes_python_keywords_suffix(keyword: str): Since Python keywords cannot be given as kwarg names, we need to use escaped versions (eg for_ => for) """ - kwargs = {f"{keyword}_": 'hi'} + kwargs = {f"{keyword}_": "hi"} assert str(body(**kwargs)) == f'' + + +def test_style_tags_are_not_escaped(): + assert str(style("&'\"<>")) == "\n".join( + [ + '", + ] + ) diff --git a/tests/render_options_test.py b/tests/render_options_test.py index 848e3dd..7c6d22b 100644 --- a/tests/render_options_test.py +++ b/tests/render_options_test.py @@ -7,6 +7,10 @@ import pyhtml as p +def test_repr_render_options(): + assert repr(p.RenderOptions(spacing="")) == "RenderOptions(spacing='')" + + def test_indent(): doc = p.body( p.RenderOptions(indent="\t"), From cad6f1ef482235188091659b1325d99200b9c100 Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Wed, 12 Feb 2025 21:52:23 +1100 Subject: [PATCH 21/23] Fix linting --- tests/basic_rendering_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/basic_rendering_test.py b/tests/basic_rendering_test.py index 81982e7..0f59aac 100644 --- a/tests/basic_rendering_test.py +++ b/tests/basic_rendering_test.py @@ -17,10 +17,10 @@ head, html, input, + pre, script, span, title, - pre, ) From 924c3f695d1b81015d883d8aaa7f21c129852146 Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Wed, 12 Feb 2025 21:54:27 +1100 Subject: [PATCH 22/23] Update module docstring --- pyhtml/__init__.py | 249 +-------------------------------------------- 1 file changed, 5 insertions(+), 244 deletions(-) diff --git a/pyhtml/__init__.py b/pyhtml/__init__.py index 22688e8..6c61680 100644 --- a/pyhtml/__init__.py +++ b/pyhtml/__init__.py @@ -1,11 +1,13 @@ """ -# PyHTML Enhanced +# `` A library for building HTML documents with a simple and learnable syntax, inspired by (and similar to) [Cenk Altı's PyHTML library](https://github.com/cenkalti/pyhtml), but with improved documentation and type safety. +Learn more by reading [the documentation](https://comp1010unsw.github.io/pyhtml-enhanced/). + ## Features * Inline documentation and type safety for all tags. @@ -16,7 +18,7 @@ * No dependencies. -* 100% test coverage +* 100% test coverage. ## Usage @@ -45,252 +47,11 @@

Hello, world!

-

- This is my amazing website! -

+

This is my amazing website!

``` - -### Creating elements - -Every HTML tag is represented by a `class` that generates that HTML code. For -example, to create a `
` element, you could use: - -```py ->>> line_break = p.br() ->>> print(str(line_break)) -
- -``` - -### Adding children to elements - -Any arguments to a tag are used as a child element to the created HTML element. -For example, to create a heading with the text `"My awesome website"`, you -could use - -```py ->>> heading = p.h1("My awesome website") ->>> print(str(heading)) -

- My awesome website -

- -``` - -### Adding attributes to elements - -Any keyword arguments to a tag are used as an attribute of the created HTML -element. For example, to create a form submit button, you could use - -```py ->>> submit_button = p.input(type="submit") ->>> print(str(submit_button)) - - -``` - -### Adding attributes and children - -In HTML, attributes are specified within the opening tag. Contrastingly, Python -requires keyword arguments (attributes) to be specified after regular arguments -(children). To maintain similarity to writing regular HTML, you can call an -element in order to add more attributes and children. For example, to create -a link to PyHTML's GitHub page, you could use - -```py ->>> my_link = p.a(href="https://github.com/COMP1010UNSW/pyhtml-enhanced")("Take a look at the code") ->>> print(str(my_link)) - - Take a look at the code - - -``` - -### HTML comments - -You can add comments to HTML (useful for debugging) by using the `Comment` tag. - -```py ->>> comment = p.Comment("This is an HTML comment") ->>> print(str(comment)) - - -``` - -### Rendering HTML - -Converting your PyHTML into HTML is as simple as stringifying it! - -```py ->>> print(str(p.i("How straightforward!"))) - - How straightforward! - - -``` - -### Custom tags - -Since this library includes all modern HTML tags, it is very unlikely that -you'll need to do create a custom tag. However if you really need to, you can -create a class deriving from `Tag`. - -```py ->>> class fancytag(p.Tag): -... ... ->>> print(fancytag()) - - -``` - -#### Tag base classes - -You can derive from various other classes to get more control over how your tag -is rendered: - -* `Tag`: default rendering. - -* `SelfClosingTag`: tag is self-closing, meaning that no child elements are - accepted. - -* `WhitespaceSensitiveTag`: tag is whitespace-sensitive, meaning that its - child elements are not indented. - -#### Class properties - -* `children`: child elements -* `attributes`: element attributes - -#### Rendering control functions - -You can also override various functions to control the existing rendering. - -* `_get_tag_name`: return the name to use for the tag. For example returning - `"foo"` would produce ``. - -* `_get_default_attributes`: return the default values for attributes. - -* `_get_tag_pre_content`: return the pre-content for the tag. For example, the - `` tag uses this to add the `` before the opening tag. - -* `_escape_children`: return whether the string child elements should be - escaped to prevent HTML injection. - -* `_render`: render the element and its children, returning the list of lines - to use for the output. Overriding this should be a last resort, as it is easy - to subtly break the rendering process if you aren't careful. - -Refer to the documentation for `Tag` for more information. - -## Differences to PyHTML - -There are some minor usage differences compared to the original PyHTML library. - -Uninstantiated classes are only rendered if they are given as the child of an -instantiated element. - -```py ->>> p.br - ->>> print(str(p.html(p.body(p.br)))) - - - -
- - - -``` - -Calling an instance of a `Tag` will return a new tag containing all elements of -the original tag combined with the new attributes and children, but will not -modify the original instance, as I found the old behaviour confusing and -bug-prone. - -```py ->>> para = p.p("Base paragraph") ->>> para2 = para("Extra text") ->>> para2 -

- Base paragraph - Extra text -

->>> para -

- Base paragraph -

- -``` - -## Known issues - -There are a couple of things I haven't gotten round to sorting out yet - -* [ ] Add default attributes to more tags -* [ ] Some tags (eg `
`, `