Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(HTML)!: HTML class no longer inherits from str #86

Merged
merged 51 commits into from
Sep 19, 2024
Merged
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
9117a8a
Expose `ReprHtml` class
schloerke Apr 22, 2024
179f103
Formatting
schloerke Apr 22, 2024
6b28049
`.get_html_string()` now returns `str` instead of `HTML`
schloerke Apr 22, 2024
991852f
Do not support `HTML` for attrs
schloerke Apr 22, 2024
7a2c188
Update `HTML` class to not inherit from `str`
schloerke Apr 22, 2024
0e2ea4b
Update CHANGELOG.md
schloerke Apr 22, 2024
ee8b640
Update checkout and setup python to latest versions
schloerke Apr 23, 2024
48037ed
Add job to check dev shiny with pypi & dev htmltools
schloerke Apr 23, 2024
8abe640
TagAttrValue and TagAttrDict now support HTML values
schloerke Apr 23, 2024
372f6b2
Make sure single child HTML or text has no white space around it
schloerke Apr 23, 2024
f25ab9c
Loosen type restriction on HTML input object
schloerke Apr 23, 2024
4876bea
bug: Return HTML text when requested
schloerke Apr 23, 2024
e6848a2
Fix failing test; JSX tagified should equal itself, now compares stri…
schloerke Apr 23, 2024
9f7bae4
Check dev Shiny w/ dev Htmltools
schloerke Apr 23, 2024
1428c97
Lints
schloerke Apr 23, 2024
05531b7
Use editable install of htmltools; Run tests before checking shiny
schloerke Apr 23, 2024
05bc8da
Add equality method to `HTML`
schloerke Apr 23, 2024
b7e2473
Fix bug where html + str resulted in str; Now it returns HTML
schloerke Apr 23, 2024
5e4443d
Update pytest.yaml
schloerke Apr 23, 2024
8d0614c
Do not allow HTML in Tag attrs
schloerke Apr 23, 2024
633466b
Remove `_html_escape` for faicons 0.2.1
schloerke Apr 23, 2024
855e46d
Update CHANGELOG.md
schloerke Apr 23, 2024
6cde017
Merge branch 'main' into html_not_str
schloerke Sep 18, 2024
4e164b1
Use `UserString` as base class for HTML
schloerke Sep 18, 2024
1b658db
lint
schloerke Sep 18, 2024
c2d4a58
Add `__radd__()` support for `HTML` to add `HTML()` to `str` objects:…
schloerke Sep 18, 2024
53334c5
Install all of shiny to get the custom version
schloerke Sep 18, 2024
6a094d2
Merge branch 'main' into html_not_str
schloerke Sep 18, 2024
016bc39
Discard changes to .vscode/settings.json
schloerke Sep 18, 2024
75bbd6a
Apply suggestions from code review; Remove unnecessary str calls
schloerke Sep 18, 2024
9ae85d9
Discard changes to htmltools/_util.py
schloerke Sep 18, 2024
f67f5dc
Add another breaking change for HTML type addition
schloerke Sep 18, 2024
4ead746
Export `is_tag_node()`, `is_tag_child()` and `consolidate_attrs()`
schloerke Sep 19, 2024
70f4e06
Update CHANGELOG.md
schloerke Sep 19, 2024
19c5eba
`add_style()` added support for receiving `HTML` objects
schloerke Sep 19, 2024
242da23
Use `isinstance(x, ReprHtml)` rather than looking for specific field
schloerke Sep 19, 2024
c2cd993
Update test_jsx_tags.py
schloerke Sep 19, 2024
d1ea0e0
Allow attr values to be HTML
schloerke Sep 19, 2024
19c4e8c
Move py-shiny testing to it's own job, away from pytest.yaml
schloerke Sep 19, 2024
90c5a84
Use shiny's `ci-install-deps` target to build faster
schloerke Sep 19, 2024
25d5558
Use dev shiny branch for faster debugging
schloerke Sep 19, 2024
3fc4b1c
Update shiny.yaml
schloerke Sep 19, 2024
88e4d78
Update shiny.yaml
schloerke Sep 19, 2024
40b7e1c
Update py-shiny dev branch to main. Use new check action from posit-d…
schloerke Sep 19, 2024
2483d2b
checkout htmltools within _dev/htmltools so that py-shiny checks will…
schloerke Sep 19, 2024
2448ede
auto accept uninstall
schloerke Sep 19, 2024
0d25a1a
Revert TagAttrDict changelog entry about not supporting HTML
schloerke Sep 19, 2024
8e0e26c
Remove outdate changelog
schloerke Sep 19, 2024
7f9bdc6
Apply suggestions from code review
schloerke Sep 19, 2024
86563dc
format comment
schloerke Sep 19, 2024
f6badbf
bump dev version to 0.5.3.9001
schloerke Sep 19, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 53 additions & 4 deletions .github/workflows/pytest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ jobs:
shell: bash

steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
Expand All @@ -39,16 +39,65 @@ jobs:
- name: pyright, flake8, black and isort
run: |
make check
shiny:
runs-on: ${{ matrix.os }}
strategy:
matrix:
python-version: ["3.12"]
os: [ubuntu-latest]
fail-fast: false
defaults:
run:
shell: bash

steps:
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- uses: actions/checkout@v4
with:
path: htmltools
- uses: actions/checkout@v4
with:
repository: posit-dev/py-shiny
path: shiny
fetch-depth: 0

- name: Install dev Shiny
run: |
python -m pip install --upgrade pip
cd shiny
pip install -e ".[dev,test]"

- name: Check dev Shiny w/ regular Htmltools
continue-on-error: true
run: |
cd shiny
make test check

- name: Install dev htmltools dependencies
run: |
cd htmltools
python -m pip install --upgrade pip
pip install -e ".[dev,test]"
make install

- name: Check dev Shiny w/ dev Htmltools
run: |
cd shiny
make test check

deploy:
name: "Deploy to PyPI"
runs-on: ubuntu-latest
if: github.event_name == 'release'
needs: [build]
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: "Set up Python 3.10"
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Install dependencies
Expand Down
1 change: 0 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
"files.trimTrailingWhitespace": true,
"files.insertFinalNewline": true,
"python.formatting.provider": "black",
"python.linting.flake8Enabled": true,
schloerke marked this conversation as resolved.
Show resolved Hide resolved
"editor.tabSize": 2,
"files.encoding": "utf8",
"files.eol": "\n",
Expand Down
18 changes: 17 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,23 @@ All notable changes to htmltools for Python will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [UNRELEASED]
## [Unreleased] YYYY-MM-DD

### Breaking changes

* `HTML` no longer inherits from `str`. It now inherits from `collections.UserString`. This was done to avoid confusion between `str` and `HTML` objects. (#86)

* `Tag` attributes no longer silently support `HTML` as values. (#86)
schloerke marked this conversation as resolved.
Show resolved Hide resolved

* `Tag` and `TagList`'s method `.get_html_string()` now both return `str` instead of `HTML`. (#86)
schloerke marked this conversation as resolved.
Show resolved Hide resolved

* `TagAttrDict` no longer silently supports `HTML` values for attrs. Only `str` values are supported. (#86)
schloerke marked this conversation as resolved.
Show resolved Hide resolved

schloerke marked this conversation as resolved.
Show resolved Hide resolved
### New features

* Exported `ReprHtml` protocol class. If an object has a `_repr_html_` method, then it is of instance `ReprHtml`. (#86)

### Bug fixes

* Fixed an issue with `HTMLTextDocument()` returning extracted `HTMLDependency()`s in a non-determistic order. (#95)

Expand Down
7 changes: 7 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,10 @@ check: pyright lint ## check code quality with pyright, flake8, black and isort
black --check .
echo "Sorting imports with isort."
isort --check-only --diff .

check-fix: ## check/fix code quality with pyright, flake8, black and isort
@echo "Fixing code with black."
black .
@echo "Sorting imports with isort."
isort .
$(MAKE) pyright lint
6 changes: 4 additions & 2 deletions htmltools/__init__.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
__version__ = "0.5.3.9000"

from . import svg, tags
from ._core import TagAttrArg # pyright: ignore[reportUnusedImport] # noqa: F401
from ._core import TagChildArg # pyright: ignore[reportUnusedImport] # noqa: F401
from ._core import TagAttrArg # pyright: ignore[reportUnusedImport] # noqa: F401
from ._core import TagChildArg # pyright: ignore[reportUnusedImport] # noqa: F401
from ._core import (
HTML,
HTMLDependency,
HTMLDocument,
HTMLTextDocument,
MetadataNode,
RenderedHTML,
ReprHtml,
Tag,
TagAttrs,
TagAttrValue,
Expand Down Expand Up @@ -59,6 +60,7 @@
"Tagifiable",
"TagList",
"TagNode",
"ReprHtml",
"head_content",
"wrap_displayhook_handler",
"css",
Expand Down
98 changes: 70 additions & 28 deletions htmltools/_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import tempfile
import urllib.parse
import webbrowser
from collections import UserString
from copy import copy, deepcopy
from pathlib import Path
from typing import (
Expand Down Expand Up @@ -268,7 +269,7 @@ def get_html_string(
*,
add_ws: bool = True,
_escape_strings: bool = True,
) -> "HTML":
) -> str:
"""
Return the HTML string for this tag list.

Expand Down Expand Up @@ -341,7 +342,7 @@ def get_html_string(

prev_was_add_ws = False

return HTML(html_)
return html_

def get_dependencies(self, *, dedup: bool = True) -> list["HTMLDependency"]:
"""
Expand Down Expand Up @@ -429,7 +430,7 @@ def update( # type: ignore[reportIncompatibleMethodOverride] # TODO-future: fix
if kwargs:
args = args + (kwargs,)

attrz: dict[str, str | HTML] = {}
attrz: dict[str, str] = {}
for arg in args:
for k, v in arg.items():
val = self._normalize_attr_value(v)
Expand All @@ -438,8 +439,9 @@ def update( # type: ignore[reportIncompatibleMethodOverride] # TODO-future: fix
nm = self._normalize_attr_name(k)

# Preserve the HTML() when combining two HTML() attributes
# Escape any non-HTML values before combining
schloerke marked this conversation as resolved.
Show resolved Hide resolved
if nm in attrz:
val = attrz[nm] + HTML(" ") + val
val = attrz[nm] + " " + val

attrz[nm] = val

Expand All @@ -453,15 +455,15 @@ def _normalize_attr_name(x: str) -> str:
return x.replace("_", "-")

@staticmethod
def _normalize_attr_value(x: TagAttrValue) -> Optional[str]:
def _normalize_attr_value(x: TagAttrValue) -> str | None:
if x is None or x is False:
return None
if x is True:
return ""
if isinstance(x, (int, float)):
return str(x)
if isinstance(x, (HTML, str)): # type: ignore[reportUnnecessaryIsInstance]
if isinstance(x, str):
return x
if isinstance(x, (int, float)): # pyright: ignore[reportUnnecessaryIsInstance]
return str(x)
raise TypeError(
f"Invalid type for attribute: {type(x)}."
+ "Consider calling str() on this value before treating it as a tag attribute."
Expand Down Expand Up @@ -580,7 +582,7 @@ def __enter__(self) -> None:
sys.displayhook = wrap_displayhook_handler(
# self.append takes a TagChild, but the wrapper expects a function that
# takes a object.
self.append # pyright: ignore[reportArgumentType]
self.append # pyright: ignore[reportArgumentType,reportGeneralTypeIssues]
)

def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
Expand Down Expand Up @@ -660,6 +662,9 @@ def remove_class(self: TagT, class_: str) -> TagT:

# Remove the class value from the ordered set of class values
# Note: .split() splits on any whitespace and removes empty strings
cls_is_html = isinstance(cls, HTML)
if cls_is_html:
cls = cls.as_string()
schloerke marked this conversation as resolved.
Show resolved Hide resolved
new_classes = [cls_val for cls_val in cls.split() if cls_val != class_]
if len(new_classes) > 0:
# Store the new class value
Expand All @@ -685,7 +690,7 @@ def has_class(self, class_: str) -> bool:
"""
cls = self.attrs.get("class")
if cls:
return class_ in cls.split()
return class_ in str(cls).split()
schloerke marked this conversation as resolved.
Show resolved Hide resolved
else:
return False

Expand Down Expand Up @@ -732,7 +737,7 @@ def tagify(self: TagT) -> TagT:
cp.children = cp.children.tagify()
return cp

def get_html_string(self, indent: int = 0, eol: str = "\n") -> "HTML":
def get_html_string(self, indent: int = 0, eol: str = "\n") -> str:
"""
Get the HTML string representation of the tag.

Expand All @@ -749,29 +754,28 @@ def get_html_string(self, indent: int = 0, eol: str = "\n") -> "HTML":

# Write attributes
for key, val in self.attrs.items():
if not isinstance(val, HTML):
val = html_escape(val, attr=True)
val = html_escape(val, attr=True)
html_ += f' {key}="{val}"'

# Dependencies are ignored in the HTML output
children = [x for x in self.children if not isinstance(x, MetadataNode)]

# Don't enclose JSX/void elements if there are no children
if len(children) == 0 and self.name in _VOID_TAG_NAMES:
return HTML(html_ + "/>")
return html_ + "/>"

# Other empty tags are enclosed
html_ += ">"
close = "</" + self.name + ">"
if len(children) == 0:
return HTML(html_ + close)
return html_ + close

# Inline a single/empty child text node
if len(children) == 1 and isinstance(children[0], str):
if len(children) == 1 and isinstance(children[0], (str, HTML)):
if self.name in _NO_ESCAPE_TAG_NAMES:
return HTML(html_ + children[0] + close)
return html_ + str(children[0]) + close
else:
return HTML(html_ + _normalize_text(children[0]) + close)
return html_ + _normalize_text(children[0]) + close

# Write children
if self.add_ws:
Expand All @@ -787,7 +791,7 @@ def get_html_string(self, indent: int = 0, eol: str = "\n") -> "HTML":
if self.add_ws:
html_ += eol + indent_str

return HTML(html_ + close)
return html_ + close

def render(self) -> RenderedHTML:
"""
Expand Down Expand Up @@ -1240,7 +1244,9 @@ def _static_extract_serialized_html_deps(
# =============================================================================
# HTML strings
# =============================================================================
class HTML(str):


class HTML(UserString):
"""
Mark a string as raw HTML. This will prevent the string from being escaped when
rendered inside an HTML tag.
Expand All @@ -1254,13 +1260,48 @@ class HTML(str):
<div><p>Hello</p></div>
"""

def __init__(self, html: object) -> None:
if isinstance(html, HTML):
html = html.as_string()
schloerke marked this conversation as resolved.
Show resolved Hide resolved
super().__init__(str(html))

def __str__(self) -> str:
return self.as_string()

# HTML() + HTML() should return HTML()
def __add__(self, other: "str| HTML") -> str:
res = str.__add__(self, other)
return HTML(res) if isinstance(other, HTML) else res
# DEV NOTE: 2024/09 -
# This class is a building block for other classes, therefore it should not
# tagifiable! If this method is added, HTML strings are escaped within Shiny and
# not kept "as is"
# def tagify(self) -> Tag:
# return self.as_string()

# Cases:
# * `str + str` should return str # Not HTML's responsibility!
# * `str + HTML()` should return HTML() # Handled by HTML.__radd__()
# * `HTML() + str` should return HTML()
# * `HTML() + HTML()` should return HTML()
def __add__(self, other: object) -> HTML:
if isinstance(other, HTML):
# HTML strings should be concatenated without escaping
# Convert each element to strings, then concatenate them, and return HTML
# Case: `HTML() + HTML()`
return HTML(self.as_string() + other.as_string())

# Non-HTML text added to HTML should be escaped before being added
# Convert each element to strings, then concatenate them, and return HTML
# Case: `HTML() + str`
return HTML(self.as_string() + html_escape(str(other)))
schloerke marked this conversation as resolved.
Show resolved Hide resolved

# Right side addition for when types are: `str + HTML()` or `unknown + HTML()`
def __radd__(self, other: object) -> HTML:
# Non-HTML text added to HTML should be escaped before being added
# Convert each element to strings, then concatenate them, and return HTML
# Case: `str + HTML()`
return HTML(html_escape(str(other)) + self.as_string())

def __eq__(self, x: object) -> bool:
# Set `x` first so that it can dispatch to the other object's __eq__ method as we've upgraded to `str`
return x == self.as_string()
schloerke marked this conversation as resolved.
Show resolved Hide resolved

def __repr__(self) -> str:
return self.as_string()
Expand All @@ -1269,7 +1310,8 @@ def _repr_html_(self) -> str:
return self.as_string()

def as_string(self) -> str:
return self + ""
# Returns a new string
return self.data + ""


# =============================================================================
Expand Down Expand Up @@ -1724,7 +1766,7 @@ def _tagchilds_to_tagnodes(x: Iterable[TagChild]) -> list[TagNode]:
for i, item in enumerate(result):
if isinstance(item, (int, float)):
result[i] = str(item)
elif not isinstance(item, (Tagifiable, Tag, MetadataNode, ReprHtml, str)):
elif not isinstance(item, (HTML, Tagifiable, Tag, MetadataNode, ReprHtml, str)):
raise TypeError(
f"Invalid tag item type: {type(item)}. "
+ "Consider calling str() on this value before treating it as a tag item."
Expand Down Expand Up @@ -1776,9 +1818,9 @@ def _tag_show(
raise Exception(f"Unknown renderer {renderer}")


def _normalize_text(txt: str) -> str:
def _normalize_text(txt: str | HTML) -> str:
if isinstance(txt, HTML):
return txt
return txt.as_string()
else:
return html_escape(txt, attr=False)

Expand Down
Loading
Loading