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.
@@ -0,0 +1,19 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ # mypyc generated files
13
+ *.so
14
+
15
+ # Sphinx
16
+ docs/_build/
17
+
18
+ # pytest-benchmark
19
+ .benchmarks/
@@ -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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; &lt;b&gt;</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&quot;b&amp;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 &lt; 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"