hyperscribe 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hyperscribe-0.1.0/.gitignore +19 -0
- hyperscribe-0.1.0/PKG-INFO +116 -0
- hyperscribe-0.1.0/README.md +91 -0
- hyperscribe-0.1.0/benchmarks/README.md +80 -0
- hyperscribe-0.1.0/docs/api.md +24 -0
- hyperscribe-0.1.0/docs/benchmarks.md +23 -0
- hyperscribe-0.1.0/docs/conf.py +35 -0
- hyperscribe-0.1.0/docs/guide.md +202 -0
- hyperscribe-0.1.0/docs/index.md +41 -0
- hyperscribe-0.1.0/docs/installation.md +27 -0
- hyperscribe-0.1.0/pyproject.toml +104 -0
- hyperscribe-0.1.0/src/hyperscribe/__init__.py +224 -0
- hyperscribe-0.1.0/src/hyperscribe/py.typed +0 -0
- hyperscribe-0.1.0/tests/test_docwriter.py +96 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: hyperscribe
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A small, dependency-free HTML templating engine that writes markup with context managers.
|
|
5
|
+
Project-URL: Homepage, https://github.com/septatrix/hyperscribe
|
|
6
|
+
Project-URL: Repository, https://github.com/septatrix/hyperscribe
|
|
7
|
+
Project-URL: Issues, https://github.com/septatrix/hyperscribe/issues
|
|
8
|
+
Author-email: Septatrix <24257556+septatrix@users.noreply.github.com>
|
|
9
|
+
Keywords: context-manager,html,markup,template-engine,templating
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Topic :: Text Processing :: Markup :: HTML
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# hyperscribe
|
|
27
|
+
|
|
28
|
+
A small, dependency-free HTML templating engine for Python.
|
|
29
|
+
You write markup as ordinary Python code with context managers,
|
|
30
|
+
and hyperscribe streams escaped, indented HTML to any file-like object.
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from io import StringIO
|
|
34
|
+
|
|
35
|
+
from hyperscribe import DocWriter
|
|
36
|
+
|
|
37
|
+
output = StringIO()
|
|
38
|
+
doc = DocWriter(output)
|
|
39
|
+
|
|
40
|
+
with doc.html(lang="en"):
|
|
41
|
+
with doc.body.main:
|
|
42
|
+
doc.h1("Hello & welcome")
|
|
43
|
+
with doc.ul:
|
|
44
|
+
for name in ("one", "two"):
|
|
45
|
+
doc.li(name)
|
|
46
|
+
|
|
47
|
+
print(output.getvalue())
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```html
|
|
51
|
+
<html lang="en">
|
|
52
|
+
<body>
|
|
53
|
+
<main>
|
|
54
|
+
<h1>Hello & welcome</h1>
|
|
55
|
+
<ul>
|
|
56
|
+
<li>one</li>
|
|
57
|
+
<li>two</li>
|
|
58
|
+
</ul>
|
|
59
|
+
</main>
|
|
60
|
+
</body>
|
|
61
|
+
</html>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Features
|
|
65
|
+
|
|
66
|
+
- Templates are plain Python: use loops, functions, and `@contextmanager` layouts.
|
|
67
|
+
- Text and attribute values are escaped by default.
|
|
68
|
+
- Output is streamed to anything with a `write(str)` method.
|
|
69
|
+
- No dependencies, fully typed, and supports Python 3.10 and newer.
|
|
70
|
+
|
|
71
|
+
## Installation
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
pip install hyperscribe
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Documentation
|
|
78
|
+
|
|
79
|
+
Full documentation is available at
|
|
80
|
+
<https://septatrix.github.io/hyperscribe/>.
|
|
81
|
+
|
|
82
|
+
## Development
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
make sync # install dependencies
|
|
86
|
+
make check # lint, type-check, check formatting, and test
|
|
87
|
+
make format # format the code with ruff
|
|
88
|
+
make docs # build the documentation
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Run `make help` to list all targets.
|
|
92
|
+
Use `uv run sphinx-autobuild docs docs/_build/html` to preview the docs while editing.
|
|
93
|
+
|
|
94
|
+
The [benchmarks](benchmarks/README.md) compare hyperscribe with other Python HTML templating libraries
|
|
95
|
+
using pytest-benchmark.
|
|
96
|
+
Their dependencies live in the optional `benchmarks` dependency group
|
|
97
|
+
and need Python 3.14:
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
uv run --group benchmarks pytest benchmarks
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Releasing
|
|
104
|
+
|
|
105
|
+
The version is derived from git tags by `hatch-vcs`.
|
|
106
|
+
To release, publish a GitHub release whose tag is `v` plus the version, such as `v0.2.0`.
|
|
107
|
+
The `Publish` workflow builds the package
|
|
108
|
+
and uploads it to PyPI through
|
|
109
|
+
[trusted publishing](https://docs.pypi.org/trusted-publishers/).
|
|
110
|
+
Copy the files in `contrib/workflows/` to `.github/workflows/` once,
|
|
111
|
+
using a credential that may edit workflows.
|
|
112
|
+
|
|
113
|
+
## Status
|
|
114
|
+
|
|
115
|
+
hyperscribe is in early development
|
|
116
|
+
and its API may change between minor releases.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# hyperscribe
|
|
2
|
+
|
|
3
|
+
A small, dependency-free HTML templating engine for Python.
|
|
4
|
+
You write markup as ordinary Python code with context managers,
|
|
5
|
+
and hyperscribe streams escaped, indented HTML to any file-like object.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from io import StringIO
|
|
9
|
+
|
|
10
|
+
from hyperscribe import DocWriter
|
|
11
|
+
|
|
12
|
+
output = StringIO()
|
|
13
|
+
doc = DocWriter(output)
|
|
14
|
+
|
|
15
|
+
with doc.html(lang="en"):
|
|
16
|
+
with doc.body.main:
|
|
17
|
+
doc.h1("Hello & welcome")
|
|
18
|
+
with doc.ul:
|
|
19
|
+
for name in ("one", "two"):
|
|
20
|
+
doc.li(name)
|
|
21
|
+
|
|
22
|
+
print(output.getvalue())
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```html
|
|
26
|
+
<html lang="en">
|
|
27
|
+
<body>
|
|
28
|
+
<main>
|
|
29
|
+
<h1>Hello & welcome</h1>
|
|
30
|
+
<ul>
|
|
31
|
+
<li>one</li>
|
|
32
|
+
<li>two</li>
|
|
33
|
+
</ul>
|
|
34
|
+
</main>
|
|
35
|
+
</body>
|
|
36
|
+
</html>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Features
|
|
40
|
+
|
|
41
|
+
- Templates are plain Python: use loops, functions, and `@contextmanager` layouts.
|
|
42
|
+
- Text and attribute values are escaped by default.
|
|
43
|
+
- Output is streamed to anything with a `write(str)` method.
|
|
44
|
+
- No dependencies, fully typed, and supports Python 3.10 and newer.
|
|
45
|
+
|
|
46
|
+
## Installation
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
pip install hyperscribe
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Documentation
|
|
53
|
+
|
|
54
|
+
Full documentation is available at
|
|
55
|
+
<https://septatrix.github.io/hyperscribe/>.
|
|
56
|
+
|
|
57
|
+
## Development
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
make sync # install dependencies
|
|
61
|
+
make check # lint, type-check, check formatting, and test
|
|
62
|
+
make format # format the code with ruff
|
|
63
|
+
make docs # build the documentation
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Run `make help` to list all targets.
|
|
67
|
+
Use `uv run sphinx-autobuild docs docs/_build/html` to preview the docs while editing.
|
|
68
|
+
|
|
69
|
+
The [benchmarks](benchmarks/README.md) compare hyperscribe with other Python HTML templating libraries
|
|
70
|
+
using pytest-benchmark.
|
|
71
|
+
Their dependencies live in the optional `benchmarks` dependency group
|
|
72
|
+
and need Python 3.14:
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
uv run --group benchmarks pytest benchmarks
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Releasing
|
|
79
|
+
|
|
80
|
+
The version is derived from git tags by `hatch-vcs`.
|
|
81
|
+
To release, publish a GitHub release whose tag is `v` plus the version, such as `v0.2.0`.
|
|
82
|
+
The `Publish` workflow builds the package
|
|
83
|
+
and uploads it to PyPI through
|
|
84
|
+
[trusted publishing](https://docs.pypi.org/trusted-publishers/).
|
|
85
|
+
Copy the files in `contrib/workflows/` to `.github/workflows/` once,
|
|
86
|
+
using a credential that may edit workflows.
|
|
87
|
+
|
|
88
|
+
## Status
|
|
89
|
+
|
|
90
|
+
hyperscribe is in early development
|
|
91
|
+
and its API may change between minor releases.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Python HTML templating benchmark
|
|
2
|
+
|
|
3
|
+
This directory compares rendering the same article list with Jinja, Mako,
|
|
4
|
+
Cheetah3, Airium, Yattag, dominate, Ludic, Hyperscript, Tagflow, Hyperscribe,
|
|
5
|
+
and `xml.etree.ElementTree`. The template includes
|
|
6
|
+
conditional featured badges, optional authors, tag loops, and comment counts.
|
|
7
|
+
It also renders a reusable topic-list component in the page navigation and for
|
|
8
|
+
each article with tags. Jinja defines the component as a macro; each Python
|
|
9
|
+
renderer exposes and calls a matching helper function.
|
|
10
|
+
The default workload contains 500 articles and varies the data to exercise each
|
|
11
|
+
branch. Jinja, Mako, and Cheetah3 each use a base template with overridable
|
|
12
|
+
navigation and content sections. The Jinja template lives in
|
|
13
|
+
`benchmarks/templates/articles.jinja2`; each renderer has its own module under
|
|
14
|
+
`benchmarks/renderers/`. Hyperscribe wraps the shared page container in a
|
|
15
|
+
`@contextmanager` function and uses the standalone package in `src/hyperscribe/`.
|
|
16
|
+
It renders each topic list on a single line with `doc.inline()`,
|
|
17
|
+
which suppresses line breaks and indentation inside its block,
|
|
18
|
+
so its output stays close to Jinja's.
|
|
19
|
+
Tagflow is the unrelated [`tagflow`](https://pypi.org/project/tagflow/) package from PyPI,
|
|
20
|
+
which builds an ElementTree through context managers backed by context variables.
|
|
21
|
+
|
|
22
|
+
The benchmarks are a [pytest-benchmark](https://pytest-benchmark.readthedocs.io/) suite.
|
|
23
|
+
Their dependencies are in the optional `benchmarks` dependency group
|
|
24
|
+
and require Python 3.14.
|
|
25
|
+
Run everything with:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
uv run --group benchmarks pytest benchmarks
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Select renderers or escaping cases with `-k`,
|
|
32
|
+
and change the workload with `--items`
|
|
33
|
+
(the default is 500 articles):
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
uv run --group benchmarks pytest benchmarks -k "Jinja or Hyperscribe" --items 1000
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Use pytest-benchmark's own options to control the measurement
|
|
40
|
+
and to sort, compare, or save results:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
uv run --group benchmarks pytest benchmarks \
|
|
44
|
+
--benchmark-sort=mean --benchmark-min-rounds=50 --benchmark-warmup=on \
|
|
45
|
+
--benchmark-save=baseline
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`--benchmark-skip` runs only the output and memory checks
|
|
49
|
+
and `--benchmark-only` runs only the timings.
|
|
50
|
+
|
|
51
|
+
## Rendering
|
|
52
|
+
|
|
53
|
+
`test_render.py` renders the same article list with each library.
|
|
54
|
+
`test_output` first checks that every renderer produces the same document:
|
|
55
|
+
it compares tags, attributes, and visible text
|
|
56
|
+
while ignoring indentation-only whitespace,
|
|
57
|
+
because formatters lay out whitespace differently.
|
|
58
|
+
`test_render` then times the render,
|
|
59
|
+
excluding input construction and Jinja template compilation.
|
|
60
|
+
`test_memory` measures the peak traced Python memory of one render
|
|
61
|
+
after a warm-up render
|
|
62
|
+
and, like the output size, prints it in a table after the timing results.
|
|
63
|
+
`tracemalloc` does not include native allocations.
|
|
64
|
+
|
|
65
|
+
To regenerate Jinja's generated Python source for inspection, run:
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
uv run --group benchmarks python -m benchmarks.compile_jinja
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The generated source is saved in `benchmarks/generated_jinja.py`.
|
|
72
|
+
It shows the Python function Jinja compiles for `articles.jinja2`;
|
|
73
|
+
the benchmark still uses Jinja's normal `Template.render()` path.
|
|
74
|
+
|
|
75
|
+
## Escaping
|
|
76
|
+
|
|
77
|
+
`test_escape.py` compares text and attribute escaping independently of document rendering.
|
|
78
|
+
It benchmarks `html.escape`, chained `str.replace`,
|
|
79
|
+
and a precomputed `str.maketrans` table with `str.translate`
|
|
80
|
+
on short and long strings, and checks that they agree.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# API reference
|
|
2
|
+
|
|
3
|
+
```{eval-rst}
|
|
4
|
+
.. module:: hyperscribe
|
|
5
|
+
|
|
6
|
+
.. autoclass:: hyperscribe.DocWriter
|
|
7
|
+
:members: tag, inline, text, write_raw
|
|
8
|
+
:special-members: __call__, __getattr__
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Tag objects
|
|
12
|
+
|
|
13
|
+
Accessing an attribute on a {class}`~hyperscribe.DocWriter` returns a tag builder.
|
|
14
|
+
Calling {meth}`~hyperscribe.DocWriter.tag` returns a tag context.
|
|
15
|
+
Both are documented here because they appear in the signatures above,
|
|
16
|
+
but you normally do not create them yourself.
|
|
17
|
+
|
|
18
|
+
```{eval-rst}
|
|
19
|
+
.. autoclass:: hyperscribe._TagBuilder
|
|
20
|
+
:members:
|
|
21
|
+
:special-members: __call__, __getattr__
|
|
22
|
+
|
|
23
|
+
.. autoclass:: hyperscribe._TagContext
|
|
24
|
+
```
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Benchmarks
|
|
2
|
+
|
|
3
|
+
The repository includes a benchmark comparing hyperscribe with
|
|
4
|
+
Jinja, Mako, Cheetah3, Airium, Yattag, dominate, Ludic, Hyperscript, Tagflow,
|
|
5
|
+
and `xml.etree.ElementTree`.
|
|
6
|
+
Each library renders the same article list
|
|
7
|
+
with conditional badges, optional authors, tag loops and a reusable component.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
uv sync --group benchmarks
|
|
11
|
+
uv run --group benchmarks pytest benchmarks
|
|
12
|
+
uv run --group benchmarks pytest benchmarks -k "Jinja or Hyperscribe" --items 1000
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The benchmarks use [pytest-benchmark](https://pytest-benchmark.readthedocs.io/),
|
|
16
|
+
so its options for sorting, comparing, and saving results all apply.
|
|
17
|
+
The dependencies are in the optional `benchmarks` dependency group
|
|
18
|
+
and require Python 3.14.
|
|
19
|
+
|
|
20
|
+
Every renderer is first checked to produce the same document.
|
|
21
|
+
The report then shows the timing statistics for each library,
|
|
22
|
+
followed by a table of the peak traced Python memory and output size of one render.
|
|
23
|
+
See `benchmarks/README.md` in the repository for the methodology.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Sphinx configuration for the hyperscribe documentation."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version as _version
|
|
4
|
+
|
|
5
|
+
project = "hyperscribe"
|
|
6
|
+
author = "Septatrix"
|
|
7
|
+
copyright = "Septatrix"
|
|
8
|
+
release = _version("hyperscribe")
|
|
9
|
+
version = release
|
|
10
|
+
|
|
11
|
+
extensions = [
|
|
12
|
+
"myst_parser",
|
|
13
|
+
"sphinx.ext.autodoc",
|
|
14
|
+
"sphinx.ext.intersphinx",
|
|
15
|
+
"sphinx.ext.viewcode",
|
|
16
|
+
"sphinx_copybutton",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
myst_enable_extensions = ["colon_fence"]
|
|
20
|
+
myst_heading_anchors = 3
|
|
21
|
+
|
|
22
|
+
autodoc_member_order = "bysource"
|
|
23
|
+
autodoc_typehints = "signature"
|
|
24
|
+
autodoc_typehints_format = "short"
|
|
25
|
+
python_use_unqualified_type_names = True
|
|
26
|
+
|
|
27
|
+
intersphinx_mapping = {"python": ("https://docs.python.org/3", None)}
|
|
28
|
+
|
|
29
|
+
html_theme = "furo"
|
|
30
|
+
html_title = f"hyperscribe {release}"
|
|
31
|
+
html_theme_options = {
|
|
32
|
+
"source_repository": "https://github.com/septatrix/hyperscribe/",
|
|
33
|
+
"source_branch": "main",
|
|
34
|
+
"source_directory": "docs/",
|
|
35
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# User guide
|
|
2
|
+
|
|
3
|
+
## Writing a document
|
|
4
|
+
|
|
5
|
+
Everything starts with a {class}`~hyperscribe.DocWriter`,
|
|
6
|
+
which wraps any object with a `write(str)` method:
|
|
7
|
+
a {class}`io.StringIO`, an open file, or a socket wrapper.
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from io import StringIO
|
|
11
|
+
|
|
12
|
+
from hyperscribe import DocWriter
|
|
13
|
+
|
|
14
|
+
output = StringIO()
|
|
15
|
+
doc = DocWriter(output)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Accessing an attribute on the writer, such as `doc.div`, gives you a tag.
|
|
19
|
+
Tags are used in one of two ways.
|
|
20
|
+
|
|
21
|
+
### Leaf tags
|
|
22
|
+
|
|
23
|
+
Calling a tag with a string writes the complete element on one line.
|
|
24
|
+
The content is escaped.
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
doc.p("Fish & chips")
|
|
28
|
+
# <p>Fish & chips</p>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Container tags
|
|
32
|
+
|
|
33
|
+
Using a tag as a context manager writes the opening tag,
|
|
34
|
+
indents everything inside the block,
|
|
35
|
+
and writes the closing tag when the block ends.
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
with doc.ul:
|
|
39
|
+
doc.li("one")
|
|
40
|
+
doc.li("two")
|
|
41
|
+
# <ul>
|
|
42
|
+
# <li>one</li>
|
|
43
|
+
# <li>two</li>
|
|
44
|
+
# </ul>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Because the closing tag is written by the context manager,
|
|
48
|
+
it is emitted even if you leave the block early with `break` or `return`.
|
|
49
|
+
|
|
50
|
+
## Attributes
|
|
51
|
+
|
|
52
|
+
Pass attributes as keyword arguments, both to leaf and to container tags.
|
|
53
|
+
Values must be strings and are escaped for use inside double quotes.
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
doc.a("Home", href="/")
|
|
57
|
+
with doc.div(id="main"):
|
|
58
|
+
...
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Keyword names are written exactly as given.
|
|
62
|
+
For names that are not valid Python identifiers or are reserved words,
|
|
63
|
+
such as `class` or `data-id`, use {meth}`~hyperscribe.DocWriter.tag`
|
|
64
|
+
with dictionary unpacking:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
with doc.tag("div", **{"class": "card", "data-id": "7"}):
|
|
68
|
+
doc.p("content")
|
|
69
|
+
# <div class="card" data-id="7">
|
|
70
|
+
# <p>content</p>
|
|
71
|
+
# </div>
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Nested tags
|
|
75
|
+
|
|
76
|
+
Chaining attributes opens several tags at once,
|
|
77
|
+
which avoids deeply nested `with` statements.
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
with doc.body.main:
|
|
81
|
+
doc.h1("Title")
|
|
82
|
+
# <body>
|
|
83
|
+
# <main>
|
|
84
|
+
# <h1>Title</h1>
|
|
85
|
+
# </main>
|
|
86
|
+
# </body>
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Chained tags work for leaves, too.
|
|
90
|
+
`doc.small.span("hi", title="t")` produces `<small><span title="t">hi</span></small>`,
|
|
91
|
+
with the attributes applied to the innermost tag.
|
|
92
|
+
|
|
93
|
+
## Text
|
|
94
|
+
|
|
95
|
+
Use {meth}`~hyperscribe.DocWriter.text`, or call the writer directly,
|
|
96
|
+
to write escaped text on its own line.
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
with doc.p:
|
|
100
|
+
doc("Some ")
|
|
101
|
+
doc.strong("important")
|
|
102
|
+
doc(" text")
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Inline formatting
|
|
106
|
+
|
|
107
|
+
By default every tag gets its own line.
|
|
108
|
+
That is what you want for structure, but it inserts whitespace into running text.
|
|
109
|
+
Wrap content in {meth}`~hyperscribe.DocWriter.inline` to keep it on one line:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
with doc.inline(), doc.li:
|
|
113
|
+
doc("hi, ")
|
|
114
|
+
doc.b("there")
|
|
115
|
+
# <li>hi, <b>there</b></li>
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The block is indented and ends its line like any other tag.
|
|
119
|
+
Inline blocks may be nested;
|
|
120
|
+
normal formatting resumes once the outermost one exits.
|
|
121
|
+
|
|
122
|
+
### Raw output
|
|
123
|
+
|
|
124
|
+
{meth}`~hyperscribe.DocWriter.write_raw` writes a string exactly as given,
|
|
125
|
+
without escaping or indentation.
|
|
126
|
+
Use it for a doctype or for markup you have already made safe.
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
doc.write_raw("<!DOCTYPE html>\n")
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
```{warning}
|
|
133
|
+
`write_raw` bypasses escaping.
|
|
134
|
+
Never pass it untrusted input.
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Escaping
|
|
138
|
+
|
|
139
|
+
Text content escapes `&`, `<` and `>`.
|
|
140
|
+
Attribute values additionally escape quotes.
|
|
141
|
+
Nothing else is escaped,
|
|
142
|
+
so do not use hyperscribe to write into `<script>` or `<style>` elements
|
|
143
|
+
with untrusted data.
|
|
144
|
+
|
|
145
|
+
## Void elements
|
|
146
|
+
|
|
147
|
+
hyperscribe does not know which HTML elements are void.
|
|
148
|
+
A tag is only written when it is called with content or used as a context manager,
|
|
149
|
+
so `doc.br` on its own does nothing.
|
|
150
|
+
Write void elements with {meth}`~hyperscribe.DocWriter.write_raw`:
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
doc.write_raw("<br>\n")
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Layouts and components
|
|
157
|
+
|
|
158
|
+
Since templates are Python, components are functions
|
|
159
|
+
and layouts can be generators or `@contextmanager` functions.
|
|
160
|
+
The layout below yields once per replaceable section:
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
from collections.abc import Iterator
|
|
164
|
+
from typing import Literal
|
|
165
|
+
|
|
166
|
+
from hyperscribe import DocWriter
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def topic_list(doc: DocWriter, topics: list[str]) -> None:
|
|
170
|
+
with doc.inline(), doc.div:
|
|
171
|
+
for index, topic in enumerate(topics):
|
|
172
|
+
if index:
|
|
173
|
+
doc(", ")
|
|
174
|
+
doc.span(topic)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def page(doc: DocWriter) -> Iterator[Literal["head", "content"]]:
|
|
178
|
+
with doc.html(lang="en"):
|
|
179
|
+
with doc.head:
|
|
180
|
+
yield "head"
|
|
181
|
+
with doc.body:
|
|
182
|
+
yield "content"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
doc.write_raw("<!DOCTYPE html>\n")
|
|
186
|
+
for section in page(doc):
|
|
187
|
+
match section:
|
|
188
|
+
case "head":
|
|
189
|
+
doc.title("Topics")
|
|
190
|
+
case "content":
|
|
191
|
+
topic_list(doc, ["python", "html"])
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
The `benchmarks/renderers/hyperscribe.py` module in the repository
|
|
195
|
+
shows a larger example.
|
|
196
|
+
|
|
197
|
+
## Streaming
|
|
198
|
+
|
|
199
|
+
Because output goes straight to the object you provide,
|
|
200
|
+
nothing is buffered by hyperscribe itself.
|
|
201
|
+
Pass a file or a socket wrapper to send the document as it is generated,
|
|
202
|
+
or a `StringIO` to get the result as a string.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# hyperscribe
|
|
2
|
+
|
|
3
|
+
hyperscribe is a small, dependency-free HTML templating engine for Python.
|
|
4
|
+
Instead of a separate template language,
|
|
5
|
+
you write markup as Python code with context managers
|
|
6
|
+
and hyperscribe streams escaped, indented HTML to any file-like object.
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
from io import StringIO
|
|
10
|
+
|
|
11
|
+
from hyperscribe import DocWriter
|
|
12
|
+
|
|
13
|
+
output = StringIO()
|
|
14
|
+
doc = DocWriter(output)
|
|
15
|
+
|
|
16
|
+
with doc.html(lang="en"):
|
|
17
|
+
with doc.body.main:
|
|
18
|
+
doc.h1("Hello & welcome")
|
|
19
|
+
|
|
20
|
+
print(output.getvalue())
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```html
|
|
24
|
+
<html lang="en">
|
|
25
|
+
<body>
|
|
26
|
+
<main>
|
|
27
|
+
<h1>Hello & welcome</h1>
|
|
28
|
+
</main>
|
|
29
|
+
</body>
|
|
30
|
+
</html>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```{toctree}
|
|
34
|
+
:maxdepth: 2
|
|
35
|
+
:caption: Contents
|
|
36
|
+
|
|
37
|
+
installation
|
|
38
|
+
guide
|
|
39
|
+
api
|
|
40
|
+
benchmarks
|
|
41
|
+
```
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Installation
|
|
2
|
+
|
|
3
|
+
hyperscribe requires Python 3.10 or newer and has no dependencies.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
pip install hyperscribe
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
or, with [uv](https://docs.astral.sh/uv/):
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
uv add hyperscribe
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Development setup
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
git clone https://github.com/septatrix/hyperscribe
|
|
19
|
+
cd hyperscribe
|
|
20
|
+
make sync # install dependencies
|
|
21
|
+
make check # lint, type-check, check formatting, and test
|
|
22
|
+
make format # format the code with ruff
|
|
23
|
+
make docs # build the documentation
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Run `make help` to list all targets.
|
|
27
|
+
Use `uv run sphinx-autobuild docs docs/_build/html` to preview the docs while editing.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "hyperscribe"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "A small, dependency-free HTML templating engine that writes markup with context managers."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Septatrix", email = "24257556+septatrix@users.noreply.github.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = []
|
|
11
|
+
keywords = ["html", "templating", "template-engine", "markup", "context-manager"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Programming Language :: Python :: 3.14",
|
|
22
|
+
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
|
|
23
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
24
|
+
"Topic :: Text Processing :: Markup :: HTML",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/septatrix/hyperscribe"
|
|
30
|
+
Repository = "https://github.com/septatrix/hyperscribe"
|
|
31
|
+
Issues = "https://github.com/septatrix/hyperscribe/issues"
|
|
32
|
+
|
|
33
|
+
[build-system]
|
|
34
|
+
requires = ["hatchling>=1.27", "hatch-vcs>=0.4"]
|
|
35
|
+
build-backend = "hatchling.build"
|
|
36
|
+
|
|
37
|
+
[tool.hatch.version]
|
|
38
|
+
source = "vcs"
|
|
39
|
+
raw-options = { tag_regex = "^v(?P<version>.+)$", git_describe_command = ["git", "describe", "--dirty", "--tags", "--long", "--match", "v*"] }
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
packages = ["src/hyperscribe"]
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.targets.sdist]
|
|
45
|
+
include = ["src/hyperscribe", "tests", "README.md", "docs"]
|
|
46
|
+
|
|
47
|
+
[dependency-groups]
|
|
48
|
+
dev = [
|
|
49
|
+
"mypy>=1.13",
|
|
50
|
+
"pytest>=8",
|
|
51
|
+
"ruff>=0.8",
|
|
52
|
+
]
|
|
53
|
+
docs = [
|
|
54
|
+
"furo>=2024.8.6",
|
|
55
|
+
"myst-parser>=4",
|
|
56
|
+
"sphinx>=8",
|
|
57
|
+
"sphinx-autobuild>=2024.10.3",
|
|
58
|
+
"sphinx-copybutton>=0.5.2",
|
|
59
|
+
]
|
|
60
|
+
benchmarks = [
|
|
61
|
+
"airium>=0.2.6; python_version >= '3.14'",
|
|
62
|
+
"ct3>=3.4.0.post5; python_version >= '3.14'",
|
|
63
|
+
"dominate>=2.9.1; python_version >= '3.14'",
|
|
64
|
+
"pytest-benchmark>=5; python_version >= '3.14'",
|
|
65
|
+
"hyperscript>=0.3.0; python_version >= '3.14'",
|
|
66
|
+
"jinja2>=3.1.6; python_version >= '3.14'",
|
|
67
|
+
"legacy-cgi>=2.6.4; python_version >= '3.14'",
|
|
68
|
+
"ludic>=1.0.0; python_version >= '3.14'",
|
|
69
|
+
"mako>=1.4.3; python_version >= '3.14'",
|
|
70
|
+
"setuptools>=84.0.0; python_version >= '3.14'",
|
|
71
|
+
"tagflow>=0.12.0; python_version >= '3.14'",
|
|
72
|
+
"yattag>=1.16.1; python_version >= '3.14'",
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
[tool.uv]
|
|
76
|
+
default-groups = ["dev", "docs"]
|
|
77
|
+
|
|
78
|
+
[tool.pytest.ini_options]
|
|
79
|
+
testpaths = ["tests"]
|
|
80
|
+
|
|
81
|
+
[tool.ruff]
|
|
82
|
+
src = ["src", "."]
|
|
83
|
+
extend-exclude = ["benchmarks/generated_jinja.py"]
|
|
84
|
+
|
|
85
|
+
[tool.ruff.lint]
|
|
86
|
+
select = ["E", "F", "I", "B", "UP"]
|
|
87
|
+
|
|
88
|
+
[tool.mypy]
|
|
89
|
+
python_version = "3.10"
|
|
90
|
+
strict = true
|
|
91
|
+
files = ["src", "tests", "benchmarks"]
|
|
92
|
+
exclude = ["benchmarks/generated_jinja\\.py"]
|
|
93
|
+
mypy_path = "src"
|
|
94
|
+
|
|
95
|
+
[[tool.mypy.overrides]]
|
|
96
|
+
module = ["benchmarks.*"]
|
|
97
|
+
disallow_untyped_defs = false
|
|
98
|
+
disallow_incomplete_defs = false
|
|
99
|
+
disallow_untyped_calls = false
|
|
100
|
+
warn_return_any = false
|
|
101
|
+
|
|
102
|
+
[[tool.mypy.overrides]]
|
|
103
|
+
module = ["Cheetah.*", "dominate.*", "hyperscript.*", "mako.*"]
|
|
104
|
+
ignore_missing_imports = true
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""A minimal tag/text writer with Airium-style dynamic tag access."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
from contextlib import AbstractContextManager
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
from typing import TextIO, overload
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _escape_text(value: str) -> str:
|
|
13
|
+
"""Skip the replacement work for the common case with no HTML metacharacters."""
|
|
14
|
+
if "&" not in value and "<" not in value and ">" not in value:
|
|
15
|
+
return value
|
|
16
|
+
return html.escape(value, quote=False)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(slots=True)
|
|
20
|
+
class _TagContext:
|
|
21
|
+
"""Write a tag while the document tracks its nesting for indentation."""
|
|
22
|
+
|
|
23
|
+
doc: DocWriter
|
|
24
|
+
openings: tuple[str, ...]
|
|
25
|
+
closings: tuple[str, ...]
|
|
26
|
+
|
|
27
|
+
def __enter__(self) -> None:
|
|
28
|
+
for opening in self.openings:
|
|
29
|
+
self.doc._open_tag(opening)
|
|
30
|
+
|
|
31
|
+
def __exit__(
|
|
32
|
+
self,
|
|
33
|
+
exc_type: type[BaseException] | None,
|
|
34
|
+
exc_value: BaseException | None,
|
|
35
|
+
traceback: TracebackType | None,
|
|
36
|
+
) -> None:
|
|
37
|
+
for closing in reversed(self.closings):
|
|
38
|
+
self.doc._close_tag(closing)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(slots=True)
|
|
42
|
+
class _InlineContext:
|
|
43
|
+
"""Write everything in the block on one line, indented once and ended once."""
|
|
44
|
+
|
|
45
|
+
doc: DocWriter
|
|
46
|
+
previous_prefixes: list[str] = field(init=False)
|
|
47
|
+
previous_end: str = field(init=False)
|
|
48
|
+
|
|
49
|
+
def __enter__(self) -> None:
|
|
50
|
+
doc = self.doc
|
|
51
|
+
doc._write(doc._prefix(doc._depth))
|
|
52
|
+
self.previous_prefixes = doc._prefixes
|
|
53
|
+
self.previous_end = doc._end
|
|
54
|
+
doc._prefixes = doc._inline_prefixes
|
|
55
|
+
doc._end = ""
|
|
56
|
+
|
|
57
|
+
def __exit__(
|
|
58
|
+
self,
|
|
59
|
+
exc_type: type[BaseException] | None,
|
|
60
|
+
exc_value: BaseException | None,
|
|
61
|
+
traceback: TracebackType | None,
|
|
62
|
+
) -> None:
|
|
63
|
+
doc = self.doc
|
|
64
|
+
doc._prefixes = self.previous_prefixes
|
|
65
|
+
doc._end = self.previous_end
|
|
66
|
+
doc._write(doc._end)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(slots=True)
|
|
70
|
+
class _TagBuilder:
|
|
71
|
+
"""Represent a tag, usable directly or with attributes supplied by a call."""
|
|
72
|
+
|
|
73
|
+
_doc: DocWriter
|
|
74
|
+
_path: tuple[str, ...]
|
|
75
|
+
_context: _TagContext = field(init=False)
|
|
76
|
+
|
|
77
|
+
def __post_init__(self) -> None:
|
|
78
|
+
self._context = self._doc._context_for(self._path)
|
|
79
|
+
|
|
80
|
+
def __getattr__(self, name: str) -> _TagBuilder:
|
|
81
|
+
"""Return a builder for a nested tag, such as ``body`` in ``doc.body.main``."""
|
|
82
|
+
if name.startswith("_"):
|
|
83
|
+
raise AttributeError(name)
|
|
84
|
+
return _TagBuilder(self._doc, (*self._path, name))
|
|
85
|
+
|
|
86
|
+
@overload
|
|
87
|
+
def __call__(self, /, **attrs: str) -> AbstractContextManager[None]: ...
|
|
88
|
+
|
|
89
|
+
@overload
|
|
90
|
+
def __call__(self, content: str, /, **attrs: str) -> None: ...
|
|
91
|
+
|
|
92
|
+
def __call__(
|
|
93
|
+
self, content: str | None = None, /, **attrs: str
|
|
94
|
+
) -> AbstractContextManager[None] | None:
|
|
95
|
+
"""Write a leaf element when given content, else return a context manager."""
|
|
96
|
+
if content is not None:
|
|
97
|
+
context = (
|
|
98
|
+
self._doc._context_for(self._path, **attrs) if attrs else self._context
|
|
99
|
+
)
|
|
100
|
+
self._doc._render_leaf(context, content)
|
|
101
|
+
return None
|
|
102
|
+
return self._doc._context_for(self._path, **attrs)
|
|
103
|
+
|
|
104
|
+
def __enter__(self) -> None:
|
|
105
|
+
return self._context.__enter__()
|
|
106
|
+
|
|
107
|
+
def __exit__(
|
|
108
|
+
self,
|
|
109
|
+
exc_type: type[BaseException] | None,
|
|
110
|
+
exc_value: BaseException | None,
|
|
111
|
+
traceback: TracebackType | None,
|
|
112
|
+
) -> None:
|
|
113
|
+
self._context.__exit__(exc_type, exc_value, traceback)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class DocWriter:
|
|
117
|
+
"""Write escaped HTML fragments to any object with a ``write(str)`` method."""
|
|
118
|
+
|
|
119
|
+
def __init__(self, writer: TextIO) -> None:
|
|
120
|
+
self._write = writer.write
|
|
121
|
+
self._tags: dict[str, _TagContext] = {}
|
|
122
|
+
self._tag_builders: dict[str, _TagBuilder] = {}
|
|
123
|
+
self._indentation = " "
|
|
124
|
+
self._depth: int = 0
|
|
125
|
+
# Indentation by depth and the line ending written after each tag, like
|
|
126
|
+
# print(); inline blocks swap in empty strings for both.
|
|
127
|
+
self._end = "\n"
|
|
128
|
+
self._line_prefixes: list[str] = []
|
|
129
|
+
self._inline_prefixes: list[str] = []
|
|
130
|
+
self._prefixes = self._line_prefixes
|
|
131
|
+
|
|
132
|
+
def __getattr__(self, name: str) -> _TagBuilder:
|
|
133
|
+
"""Return a cached tag object usable directly or with attributes."""
|
|
134
|
+
if name.startswith("_"):
|
|
135
|
+
raise AttributeError(name)
|
|
136
|
+
|
|
137
|
+
tag_builders = self._tag_builders
|
|
138
|
+
if name not in tag_builders:
|
|
139
|
+
tag_builders[name] = _TagBuilder(self, (name,))
|
|
140
|
+
return tag_builders[name]
|
|
141
|
+
|
|
142
|
+
def __call__(self, value: str) -> None:
|
|
143
|
+
"""Write escaped text to the document."""
|
|
144
|
+
self.text(value)
|
|
145
|
+
|
|
146
|
+
def inline(self) -> AbstractContextManager[None]:
|
|
147
|
+
"""Suppress line breaks and indentation for the markup written in the block.
|
|
148
|
+
|
|
149
|
+
The block is indented and ends its line like any other tag,
|
|
150
|
+
so ``with doc.inline(), doc.div:`` renders the whole ``div`` on one line.
|
|
151
|
+
Blocks may be nested; formatting resumes once the outermost one exits.
|
|
152
|
+
"""
|
|
153
|
+
return _InlineContext(self)
|
|
154
|
+
|
|
155
|
+
def tag(self, name: str, **attrs: str) -> _TagContext:
|
|
156
|
+
"""Return a context manager for a tag with any name and attributes.
|
|
157
|
+
|
|
158
|
+
Use it for names or attributes that are not valid Python identifiers,
|
|
159
|
+
such as ``doc.tag("div", **{"class": "card"})``.
|
|
160
|
+
"""
|
|
161
|
+
if attrs:
|
|
162
|
+
attributes = "".join(
|
|
163
|
+
f' {key}="{html.escape(value, quote=True)}"'
|
|
164
|
+
for key, value in attrs.items()
|
|
165
|
+
)
|
|
166
|
+
return _TagContext(self, (f"<{name}{attributes}>",), (f"</{name}>",))
|
|
167
|
+
|
|
168
|
+
if context := self._tags.get(name):
|
|
169
|
+
return context
|
|
170
|
+
|
|
171
|
+
context = _TagContext(self, (f"<{name}>",), (f"</{name}>",))
|
|
172
|
+
self._tags[name] = context
|
|
173
|
+
return context
|
|
174
|
+
|
|
175
|
+
def _context_for(self, path: tuple[str, ...], **attrs: str) -> _TagContext:
|
|
176
|
+
"""Make a context manager for a tag chain, adding attributes to its leaf."""
|
|
177
|
+
if len(path) == 1:
|
|
178
|
+
return self.tag(path[0], **attrs)
|
|
179
|
+
|
|
180
|
+
contexts = [self.tag(name) for name in path[:-1]]
|
|
181
|
+
contexts.append(self.tag(path[-1], **attrs))
|
|
182
|
+
return _TagContext(
|
|
183
|
+
self,
|
|
184
|
+
tuple(context.openings[0] for context in contexts),
|
|
185
|
+
tuple(context.closings[0] for context in contexts),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def _generate_prefix(self, depth: int) -> str:
|
|
189
|
+
"""Return the prefix for a depth, extending the per-depth caches as needed."""
|
|
190
|
+
for missing in range(len(self._line_prefixes), depth + 1):
|
|
191
|
+
self._line_prefixes.append(self._indentation * missing)
|
|
192
|
+
self._inline_prefixes.append("")
|
|
193
|
+
return self._line_prefixes[depth]
|
|
194
|
+
|
|
195
|
+
def _prefix(self, depth: int) -> str:
|
|
196
|
+
"""Return what to write before a tag at this depth, honoring inline blocks."""
|
|
197
|
+
try:
|
|
198
|
+
return self._prefixes[depth]
|
|
199
|
+
except IndexError:
|
|
200
|
+
self._generate_prefix(depth)
|
|
201
|
+
return self._prefixes[depth]
|
|
202
|
+
|
|
203
|
+
def _render_leaf(self, context: _TagContext, text: str) -> None:
|
|
204
|
+
"""Write shorthand leaf markup inline, without context manager allocation."""
|
|
205
|
+
prefix = self._prefix(self._depth)
|
|
206
|
+
openings = "".join(context.openings)
|
|
207
|
+
closings = "".join(reversed(context.closings))
|
|
208
|
+
self._write(f"{prefix}{openings}{_escape_text(text)}{closings}{self._end}")
|
|
209
|
+
|
|
210
|
+
def _open_tag(self, opening: str) -> None:
|
|
211
|
+
self._write(f"{self._prefix(self._depth)}{opening}{self._end}")
|
|
212
|
+
self._depth += 1
|
|
213
|
+
|
|
214
|
+
def _close_tag(self, closing: str) -> None:
|
|
215
|
+
self._depth -= 1
|
|
216
|
+
self._write(f"{self._prefix(self._depth)}{closing}{self._end}")
|
|
217
|
+
|
|
218
|
+
def write_raw(self, value: str) -> None:
|
|
219
|
+
"""Write unescaped text to the document, bypassing the escaping logic."""
|
|
220
|
+
self._write(value)
|
|
221
|
+
|
|
222
|
+
def text(self, value: str) -> None:
|
|
223
|
+
"""Write escaped text to the document on its own line."""
|
|
224
|
+
self._write(f"{self._prefix(self._depth)}{_escape_text(value)}{self._end}")
|
|
File without changes
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from io import StringIO
|
|
3
|
+
|
|
4
|
+
from hyperscribe import DocWriter
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def render(build: Callable[[DocWriter], object]) -> str:
|
|
8
|
+
output = StringIO()
|
|
9
|
+
build(DocWriter(output))
|
|
10
|
+
return output.getvalue()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_leaf_tag_is_written_on_one_line() -> None:
|
|
14
|
+
assert render(lambda doc: doc.p("hello")) == "<p>hello</p>\n"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_text_is_escaped() -> None:
|
|
18
|
+
assert render(lambda doc: doc.p("a & <b>")) == "<p>a & <b></p>\n"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_attributes_are_escaped() -> None:
|
|
22
|
+
result = render(lambda doc: doc.a("x", href='a"b&c'))
|
|
23
|
+
assert result == '<a href="a"b&c">x</a>\n'
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_nested_tags_are_indented() -> None:
|
|
27
|
+
def build(doc: DocWriter) -> None:
|
|
28
|
+
with doc.ul:
|
|
29
|
+
doc.li("one")
|
|
30
|
+
|
|
31
|
+
assert render(build) == "<ul>\n <li>one</li>\n</ul>\n"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_chained_tags_open_together() -> None:
|
|
35
|
+
def build(doc: DocWriter) -> None:
|
|
36
|
+
with doc.body.main:
|
|
37
|
+
doc.p("x")
|
|
38
|
+
|
|
39
|
+
assert render(build) == "<body>\n <main>\n <p>x</p>\n </main>\n</body>\n"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_chained_leaf_with_attributes() -> None:
|
|
43
|
+
result = render(lambda doc: doc.small.span("hi", title="t"))
|
|
44
|
+
assert result == '<small><span title="t">hi</span></small>\n'
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_tag_method_supports_arbitrary_names() -> None:
|
|
48
|
+
def build(doc: DocWriter) -> None:
|
|
49
|
+
with doc.tag("my-element", id="1"):
|
|
50
|
+
doc.text("t")
|
|
51
|
+
|
|
52
|
+
assert render(build) == '<my-element id="1">\n t\n</my-element>\n'
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_call_writes_escaped_text() -> None:
|
|
56
|
+
assert render(lambda doc: doc("1 < 2")) == "1 < 2\n"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_write_raw_is_not_escaped_or_indented() -> None:
|
|
60
|
+
def build(doc: DocWriter) -> None:
|
|
61
|
+
with doc.div:
|
|
62
|
+
doc.write_raw("<hr>\n")
|
|
63
|
+
|
|
64
|
+
assert render(build) == "<div>\n<hr>\n</div>\n"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_inline_suppresses_whitespace() -> None:
|
|
68
|
+
def build(doc: DocWriter) -> None:
|
|
69
|
+
with doc.ul:
|
|
70
|
+
with doc.inline(), doc.li:
|
|
71
|
+
doc("hi, ")
|
|
72
|
+
doc.b("there")
|
|
73
|
+
|
|
74
|
+
assert render(build) == "<ul>\n <li>hi, <b>there</b></li>\n</ul>\n"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_nested_inline_resumes_formatting_after_outermost_exit() -> None:
|
|
78
|
+
def build(doc: DocWriter) -> None:
|
|
79
|
+
with doc.inline():
|
|
80
|
+
with doc.inline(), doc.span:
|
|
81
|
+
doc("a")
|
|
82
|
+
doc.p("b")
|
|
83
|
+
|
|
84
|
+
assert render(build) == "<span>a</span>\n<p>b</p>\n"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_writes_to_any_object_with_write() -> None:
|
|
88
|
+
chunks: list[str] = []
|
|
89
|
+
|
|
90
|
+
class Sink:
|
|
91
|
+
def write(self, value: str) -> int:
|
|
92
|
+
chunks.append(value)
|
|
93
|
+
return len(value)
|
|
94
|
+
|
|
95
|
+
DocWriter(Sink()).p("x") # type: ignore[arg-type]
|
|
96
|
+
assert "".join(chunks) == "<p>x</p>\n"
|