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/README.md b/README.md index 295931f..ac78f9b 100644 --- a/README.md +++ b/README.md @@ -46,9 +46,7 @@ Learn more by reading [the documentation](https://comp1010unsw.github.io/pyhtml-

Hello, world!

-

- This is my amazing website! -

+

This is my amazing website!

diff --git a/docs/api.md b/docs/api.md index fb6f2bf..745f272 100644 --- a/docs/api.md +++ b/docs/api.md @@ -9,9 +9,10 @@ - attributes - "_get_tag_name" - "_get_default_attributes" + - "_get_default_render_options" - "_get_tag_pre_content" - - "_render" - "_escape_children" + - "_render" ::: pyhtml.SelfClosingTag options: @@ -20,3 +21,10 @@ ::: pyhtml.WhitespaceSensitiveTag options: members: + +::: pyhtml.RenderOptions + options: + members: + - indent + - spacing + - union diff --git a/docs/compatibility.md b/docs/compatibility.md index db9ffd7..6cbc4c2 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -29,16 +29,29 @@ 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 -

+>>> span1 = p.span("Base paragraph") +>>> span2 = span1("Extra text") +>>> span2 + Base paragraph Extra text -

->>> para -

+ +>>> span1 + Base paragraph -

+ + +``` + +## Spacing between tag instances + +Certain tags have default spacing, which helps to avoid subtle rendering +annoyances such as unwanted spaces between linked text and punctuation in +paragraphs. + +```py +>>> print(str(p.p(p.a(href="https://example.com")("Example website"), "!"))) +

Example website!

+>>> # Notice how there is no spacing between the link and the exclamation point ``` diff --git a/docs/index.md b/docs/index.md index f776eeb..aecfe85 100644 --- a/docs/index.md +++ b/docs/index.md @@ -32,9 +32,7 @@ Build HTML documents in Python with a simple and learnable syntax.

Hello, world!

-

- This is my amazing website! -

+

This is my amazing website!

diff --git a/docs/learn/advanced.md b/docs/learn/advanced.md index e0db58a..5a48c01 100644 --- a/docs/learn/advanced.md +++ b/docs/learn/advanced.md @@ -58,7 +58,29 @@ for person in staff_members: )) ``` -### HTML comments +## Rendering options + +If you need more control over how PyHTML renders your output HTML, you can use +the [`p.RenderOptions`][pyhtml.RenderOptions] class to specify these. + +For example, if you don't want any whitespace between your HTML components, +you can use `p.RenderOptions(spacing="")` to remove it. + +```py +>>> import pyhtml as p +>>> print(str(p.div(p.RenderOptions(spacing=""))( +... p.i("No"), +... p.b("spaces"), +... p.u("here!") +... ))) +
Nospaceshere!
+ +``` + +For more information about the specific allowed properties, view +[this documentation][pyhtml.RenderOptions]. + +## HTML comments You can add comments to HTML by using the `Comment` tag. These will be included in the output HTML, which can be useful for debugging your server from your web @@ -77,8 +99,8 @@ browser. ## Embedding raw HTML By default, PyHTML [escapes certain characters](https://www.w3schools.com/html/html_entities.asp) -within strings passed to tags. This is done to avoid user content from taking -control of your resultant webpages in what is called a +within strings passed to tags. This is done to prevent user content from taking +control of your webpages in what is called a [cross-site scripting (XSS) attack](https://owasp.org/www-community/attacks/xss/). Sometimes, you may wish to embed an existing HTML string inside of PyHTML. You @@ -94,13 +116,13 @@ can do this using the `p.DangerousRawHtml` tag. ***Be careful though!*** PyHTML escapes these sequences for good reason, so don't use `DangerousRawHtml` unless you have a *very good reason*, and are -certain that your text is trusted. +certain that the text you are passing it is trusted. ## 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 -use the `create_tag` function +use the `create_tag` function. ```py >>> import pyhtml as p diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index 295cc8c..8b17741 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -5,6 +5,11 @@ --md-primary-fg-color: #ffdf77 !important; } +/* Fix spacing around nested nav items */ +.md-nav__item--nested { + padding-bottom: 8px !important; +} + .md-header { /* Text colour on header */ color: #000000 !important; diff --git a/meta/generate_tag_defs.py b/meta/generate_tag_defs.py index 19d0606..0ab9d83 100644 --- a/meta/generate_tag_defs.py +++ b/meta/generate_tag_defs.py @@ -25,6 +25,11 @@ def _get_tag_pre_content(self) -> Optional[str]: return {} """ +GET_DEFAULT_RENDER_OPTIONS = """ + def _get_default_render_options(self) -> RenderOptions: + return {} +""" + def get_template_class(name: str): try: @@ -70,8 +75,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 @@ -106,6 +113,13 @@ def generate_tag_class(output: TextIO, tag: TagInfo): file=output, ) + # Add render options function if needed + if tag.render_options is not None: + print( + GET_DEFAULT_RENDER_OPTIONS.replace("{}", f"{tag.render_options}"), + file=output, + ) + # And a nice trailing newline to make flake8 happy print(file=output) @@ -118,6 +132,7 @@ def main(output: TextIO): with open(TEMPLATES_FOLDER.joinpath("main.py")) as f: print(f.read(), file=output) + print(file=output) for tag in tags: # Generate the tag diff --git a/meta/scrape_tags.py b/meta/scrape_tags.py index 503a994..08ff32a 100644 --- a/meta/scrape_tags.py +++ b/meta/scrape_tags.py @@ -16,6 +16,8 @@ import yaml from typing_extensions import NotRequired +from pyhtml.__render_options import RenderOptions + TAGS_YAML = "meta/tags.yml" """File location to load custom tag data from""" @@ -118,6 +120,15 @@ class AttrYmlItem(TypedDict): """Python type to accept for the attribute""" +class RenderOptionsYmlItem(TypedDict): + """ + Render options as a dictionary + """ + + indent: NotRequired[str] + spacing: NotRequired[str] + + class TagsYmlItem(TypedDict): """ A tag which has suggested keys @@ -143,6 +154,11 @@ class TagsYmlItem(TypedDict): Pre-content for the element (eg ``) """ + render_options: NotRequired[RenderOptionsYmlItem] + """ + Render options for this element + """ + TagsYaml = dict[str, TagsYmlItem] """Type alias for type of tags.yml file""" @@ -216,6 +232,11 @@ class TagInfo: Pre-content for the element (eg ``) """ + render_options: Optional[RenderOptions] + """ + Render options + """ + def fetch_mdn(): """ @@ -468,6 +489,21 @@ def get_tag_pre_content(tags: TagsYaml, tag_name: str) -> Optional[str]: return tag.get("pre_content", None) +def get_tag_render_options( + tags: TagsYaml, tag_name: str +) -> Optional[RenderOptions]: + """ + Return pre-content for the tag + """ + if tag_name not in tags: + return None + tag = tags[tag_name] + if "render_options" in tag: + return RenderOptions(**tag["render_options"]) + else: + return None + + def make_mdn_link(tag: str) -> str: """Generate an MDN docs link for the given tag""" return f"{MDN_ELEMENT_PAGE}/{tag}" @@ -496,6 +532,7 @@ def elements_to_element_structs( escape_children=get_tag_escape_children(tag_attrs, name), attributes=attr_entries_to_object(tag_attrs, name), pre_content=get_tag_pre_content(tag_attrs, name), + render_options=get_tag_render_options(tag_attrs, name), ) ) diff --git a/meta/tags.yml b/meta/tags.yml index 779ae2d..75e6f81 100644 --- a/meta/tags.yml +++ b/meta/tags.yml @@ -96,6 +96,8 @@ label: p: base: StylableTag + render_options: + spacing: "" br: base: SelfClosingTag diff --git a/meta/templates/class_attrs_SelfClosingTag.py b/meta/templates/class_attrs_SelfClosingTag.py index 47df9ad..d601b3f 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: RenderOptions, {attr_args} **attributes: AttributeType, ) -> None: @@ -22,11 +22,11 @@ def __init__( attributes |= { {attr_unions} } - super().__init__(**attributes) + super().__init__(*options, **attributes) def __call__( # type: ignore self, - {kw_only} + *options: RenderOptions, {attr_args} **attributes: AttributeType, ): @@ -40,7 +40,7 @@ def __call__( # type: ignore attributes |= { {attr_unions} } - return super().__call__(**attributes) + return super().__call__(*options, **attributes) def _get_default_attributes(self, given: dict[str, AttributeType]) -> dict[str, AttributeType]: return {default_attrs} diff --git a/meta/templates/main.py b/meta/templates/main.py index 3b80466..942dab3 100644 --- a/meta/templates/main.py +++ b/meta/templates/main.py @@ -7,6 +7,8 @@ https://creativecommons.org/licenses/by-sa/2.5/ """ -from typing import Any, Optional, Union, Literal -from ..__tag_base import Tag, SelfClosingTag, WhitespaceSensitiveTag +from typing import Literal, Optional, Union + +from ..__render_options import RenderOptions +from ..__tag_base import SelfClosingTag, Tag, WhitespaceSensitiveTag from ..__types import AttributeType, ChildrenType 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" diff --git a/pyhtml/__init__.py b/pyhtml/__init__.py index c32e64c..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,381 +47,143 @@

Hello, world!

-

- 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)))) - - - -
+

This is my amazing website!

``` - -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 `
`, `',
-    ])
+    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?
@@ -158,30 +172,32 @@ def test_larger_page():
         ),
         body(
             h1("Hello, world!"),
-            p("This is my amazing website rendered with PyHTML Enhanced!"),
+            span("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!', - '

', - ' ', - '', - ]) + assert str(my_website) == "\n".join( + [ + "", + "", + " ", + " ", + " Hello, world!", + " ", + ' ', + " ", + " ", + "

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

", + " ", + " This is my amazing website rendered with PyHTML Enhanced!", + " ", + " ", + "", + ] + ) def test_format_through_repr(): @@ -196,18 +212,20 @@ def test_flatten_element_lists(): If a list of elements is given as a child element, each element should be considered as a child. """ - doc = body([p("Hello"), p("world")]) - - assert str(doc) == "\n".join([ - "", - "

", - " Hello", - "

", - "

", - " world", - "

", - "", - ]) + doc = body([span("Hello"), span("world")]) + + assert str(doc) == "\n".join( + [ + "", + " ", + " Hello", + " ", + " ", + " world", + " ", + "", + ] + ) def test_flatten_element_generators(): @@ -217,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(): @@ -232,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(): @@ -246,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(): @@ -274,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/end_to_end/flask_test.py b/tests/end_to_end/flask_test.py index ade1d3f..6bc670b 100644 --- a/tests/end_to_end/flask_test.py +++ b/tests/end_to_end/flask_test.py @@ -1,6 +1,7 @@ """ Tests to ensure our code works nicely with Flask """ + import pytest from flask import Flask from flask.testing import FlaskClient @@ -12,12 +13,14 @@ @app.get("/") def simple_route(): - return str(p.html( - p.body( - p.p("This app is to test Flask's integration with PyHTML."), - p.a(href="/no_str")("Click here to get a 500 error."), + return str( + p.html( + p.body( + p.p("Testing Flask's integration with PyHTML."), + p.a(href="/no_str")("Click here to get a 500 error."), + ) ) - )) + ) # This route intentionally returns the wrong type @@ -27,9 +30,7 @@ def no_stringify(): This route intentionally returns an un-stringified PyHTML object. We use it to test that a reasonable error message is given """ - return p.html( - p.body("Hello, world!") - ) + return p.html(p.body("Hello, world!")) @pytest.fixture @@ -51,19 +52,19 @@ def test_simple_stringify(client: FlaskClient): response = client.get("/") assert response.status_code == 200 - assert response.text == '\n'.join([ - '', - '', - ' ', - '

', - ' This app is to test Flask's integration with PyHTML.', - '

', - ' ', - ' Click here to get a 500 error.', - ' ', - ' ', - '', - ]) + assert response.text == "\n".join( + [ + "", + "", + " ", + "

Testing Flask's integration with PyHTML.

", + ' ', + " Click here to get a 500 error.", + " ", + " ", + "", + ] + ) def test_failed_to_stringify(client: FlaskClient): @@ -78,5 +79,5 @@ def test_failed_to_stringify(client: FlaskClient): # Ignore coverage since this won't be used when running tests automatically -if __name__ == '__main__': # pragma: no cover +if __name__ == "__main__": # pragma: no cover app.run(debug=True) 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 new file mode 100644 index 0000000..7c6d22b --- /dev/null +++ b/tests/render_options_test.py @@ -0,0 +1,126 @@ +""" +# Tests / render options test + +Test cases for specifying rendering options +""" + +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"), + p.span(), + p.div(), + ) + + assert str(doc) == "\n".join( + [ + "", + "\t", + "\t
", + "", + ] + ) + + +def test_mixed_indent(): + doc = p.body( + p.div( + p.RenderOptions(indent="\t"), + p.div(), + ), + ) + + assert str(doc) == "\n".join( + [ + "", + "
", + " \t
", + "
", + "", + ] + ) + + +def test_spacing(): + doc = p.body( + p.RenderOptions(spacing=" "), + p.div(), + ) + + assert str(doc) == "\n".join( + [ + "
", + ] + ) + + +def test_mixed_spacing(): + doc = p.body( + p.div( + p.RenderOptions(spacing=" "), + p.div(), + ), + ) + + assert str(doc) == "\n".join( + [ + "", + "
", + "", + ] + ) + + +def test_spacing_inner_newline(): + doc = p.body( + p.div( + p.RenderOptions(spacing=" "), + p.div( + p.RenderOptions(spacing="\n"), + p.div(), + ), + ), + ) + + assert str(doc) == "\n".join( + [ + "", + "
", + "
", + "
", + "", + ] + ) + + +def test_indent_and_spacing_inner_newline(): + doc = p.body( + p.div( + p.RenderOptions(spacing=" "), + p.div( + p.RenderOptions(spacing="\n", indent="\t"), + p.div(), + ), + ), + ) + + assert str(doc) == "\n".join( + [ + "", + "
", + " \t
", + "
", + "", + ] + ) + + +def test_default_render_options(): + doc = p.p("Paragraph") + assert str(doc) == "

Paragraph

" diff --git a/tests/test_type_safety.yml b/tests/test_type_safety.yml index 719fbd5..4f2f225 100644 --- a/tests/test_type_safety.yml +++ b/tests/test_type_safety.yml @@ -31,6 +31,11 @@ import pyhtml as p p.html({{ arg }}) +- case: children_render_options + main: | + import pyhtml as p + p.html(p.RenderOptions()) + - case: children_disallowed_types parametrized: - arg: None