python-fragments 0.28__tar.gz → 0.31__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.
Files changed (51) hide show
  1. python_fragments-0.31/LICENSE +21 -0
  2. python_fragments-0.31/PKG-INFO +88 -0
  3. {python_fragments-0.28 → python_fragments-0.31}/README.md +11 -1
  4. {python_fragments-0.28 → python_fragments-0.31}/fragments/ast_nodes.py +66 -23
  5. {python_fragments-0.28 → python_fragments-0.31}/fragments/grammar.py +26 -3
  6. {python_fragments-0.28 → python_fragments-0.31}/fragments/html/elements.py +4 -6
  7. {python_fragments-0.28 → python_fragments-0.31}/fragments/types.py +1 -2
  8. {python_fragments-0.28 → python_fragments-0.31}/pyproject.toml +2 -2
  9. python_fragments-0.31/python_fragments.egg-info/PKG-INFO +88 -0
  10. {python_fragments-0.28 → python_fragments-0.31}/python_fragments.egg-info/SOURCES.txt +2 -0
  11. python_fragments-0.31/tests/test_end_to_end.py +158 -0
  12. {python_fragments-0.28 → python_fragments-0.31}/tests/test_grammar.py +90 -0
  13. {python_fragments-0.28 → python_fragments-0.31}/tests/test_html_elements.py +2 -2
  14. {python_fragments-0.28 → python_fragments-0.31}/tests/test_source_map.py +1 -1
  15. python_fragments-0.28/PKG-INFO +0 -55
  16. python_fragments-0.28/python_fragments.egg-info/PKG-INFO +0 -55
  17. {python_fragments-0.28 → python_fragments-0.31}/fragments/__init__.py +0 -0
  18. {python_fragments-0.28 → python_fragments-0.31}/fragments/cli.py +0 -0
  19. {python_fragments-0.28 → python_fragments-0.31}/fragments/html/__init__.py +0 -0
  20. {python_fragments-0.28 → python_fragments-0.31}/fragments/loader.py +0 -0
  21. {python_fragments-0.28 → python_fragments-0.31}/fragments/lsp/__init__.py +0 -0
  22. {python_fragments-0.28 → python_fragments-0.31}/fragments/lsp/based_proxy.py +0 -0
  23. {python_fragments-0.28 → python_fragments-0.31}/fragments/lsp/client_message_handlers/__init__.py +0 -0
  24. {python_fragments-0.28 → python_fragments-0.31}/fragments/lsp/client_message_handlers/code_actions.py +0 -0
  25. {python_fragments-0.28 → python_fragments-0.31}/fragments/lsp/client_message_handlers/completion.py +0 -0
  26. {python_fragments-0.28 → python_fragments-0.31}/fragments/lsp/client_message_handlers/definition.py +0 -0
  27. {python_fragments-0.28 → python_fragments-0.31}/fragments/lsp/client_message_handlers/diagnostics.py +0 -0
  28. {python_fragments-0.28 → python_fragments-0.31}/fragments/lsp/client_message_handlers/document_highlight.py +0 -0
  29. {python_fragments-0.28 → python_fragments-0.31}/fragments/lsp/client_message_handlers/document_symbols.py +0 -0
  30. {python_fragments-0.28 → python_fragments-0.31}/fragments/lsp/client_message_handlers/folding_range.py +0 -0
  31. {python_fragments-0.28 → python_fragments-0.31}/fragments/lsp/client_message_handlers/hover.py +0 -0
  32. {python_fragments-0.28 → python_fragments-0.31}/fragments/lsp/client_message_handlers/inlay_hints.py +0 -0
  33. {python_fragments-0.28 → python_fragments-0.31}/fragments/lsp/client_message_handlers/lifecycle.py +0 -0
  34. {python_fragments-0.28 → python_fragments-0.31}/fragments/lsp/client_message_handlers/references.py +0 -0
  35. {python_fragments-0.28 → python_fragments-0.31}/fragments/lsp/client_message_handlers/rename.py +0 -0
  36. {python_fragments-0.28 → python_fragments-0.31}/fragments/lsp/client_message_handlers/semantic_tokens.py +0 -0
  37. {python_fragments-0.28 → python_fragments-0.31}/fragments/lsp/client_message_handlers/signature_help.py +0 -0
  38. {python_fragments-0.28 → python_fragments-0.31}/fragments/lsp/file_state.py +0 -0
  39. {python_fragments-0.28 → python_fragments-0.31}/fragments/lsp/message_queue.py +0 -0
  40. {python_fragments-0.28 → python_fragments-0.31}/fragments/lsp/pyright_notification_handlers/__init__.py +0 -0
  41. {python_fragments-0.28 → python_fragments-0.31}/fragments/lsp/pyright_notification_handlers/capability.py +0 -0
  42. {python_fragments-0.28 → python_fragments-0.31}/fragments/lsp/pyright_notification_handlers/configuration.py +0 -0
  43. {python_fragments-0.28 → python_fragments-0.31}/fragments/lsp/pyright_notification_handlers/diagnostics.py +0 -0
  44. {python_fragments-0.28 → python_fragments-0.31}/fragments/lsp/types.py +0 -0
  45. {python_fragments-0.28 → python_fragments-0.31}/fragments/source.py +0 -0
  46. {python_fragments-0.28 → python_fragments-0.31}/fragments/transpiler.py +0 -0
  47. {python_fragments-0.28 → python_fragments-0.31}/python_fragments.egg-info/dependency_links.txt +0 -0
  48. {python_fragments-0.28 → python_fragments-0.31}/python_fragments.egg-info/entry_points.txt +0 -0
  49. {python_fragments-0.28 → python_fragments-0.31}/python_fragments.egg-info/requires.txt +0 -0
  50. {python_fragments-0.28 → python_fragments-0.31}/python_fragments.egg-info/top_level.txt +0 -0
  51. {python_fragments-0.28 → python_fragments-0.31}/setup.cfg +0 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The Running Algorithm
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-fragments
3
+ Version: 0.31
4
+ Summary: Modern HTML template rendering in Python
5
+ Author-email: The Running Algorithm <services@therunningalgorithm.info>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 The Running Algorithm
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Requires-Python: >=3.12
29
+ Description-Content-Type: text/markdown
30
+ License-File: LICENSE
31
+ Provides-Extra: lsp
32
+ Requires-Dist: basedpyright>=1.39.0; extra == "lsp"
33
+ Requires-Dist: lsprotocol>=2025.0.0; extra == "lsp"
34
+ Provides-Extra: dev
35
+ Requires-Dist: pytest>=8.4.1; extra == "dev"
36
+ Dynamic: license-file
37
+
38
+ # Python Fragments
39
+
40
+ > **This package is in early development and not yet stable. The API may change without notice between releases.**
41
+
42
+ Modern HTML template rendering in Python — no build step, no template files, and native HTML awareness out of the box.
43
+
44
+ ```python
45
+ from fragments import loader # isort: skip
46
+
47
+ from fastapi import FastAPI
48
+ from fastapi.responses import HTMLResponse
49
+ from components import Layout, PostCard
50
+
51
+ app = FastAPI()
52
+
53
+ POSTS = [...]
54
+
55
+ @app.get("/", response_class=HTMLResponse)
56
+ async def index() -> str:
57
+ published = [p for p in POSTS if p.published]
58
+ return <>
59
+ <Layout title="My Blog">
60
+ <h1>Latest Posts</h1>
61
+ <PostCard for={{ post in published }} post={{ post }} />
62
+ </Layout>
63
+ </>
64
+ ```
65
+
66
+ ## IDE Support
67
+
68
+ Type checking, completions, hover docs, go-to-definition, rename, and semantic highlighting — all working inside fragment syntax.
69
+
70
+ ![VS Code completions demo](docs/assets/vscode.gif)
71
+
72
+ ## Installation
73
+
74
+ ```bash
75
+ pip install python-fragments
76
+ ```
77
+
78
+ Register the loader at your application's entry point, before importing any modules that contain fragments:
79
+
80
+ ```python
81
+ from fragments import loader # isort: skip
82
+ ```
83
+
84
+ Any `.py` file containing `<>` is transpiled automatically. Nothing else to configure.
85
+
86
+ ## Feedback and feature requests
87
+
88
+ Bug reports and feature requests are welcome via [GitHub Issues](https://github.com/TheRunningAlgorithm2/python-fragments/issues). The project is not currently accepting code contributions.
@@ -2,7 +2,7 @@
2
2
 
3
3
  > **This package is in early development and not yet stable. The API may change without notice between releases.**
4
4
 
5
- Production-ready, modern HTML template rendering in Python — no build step, no template files, and native HTML awareness out of the box.
5
+ Modern HTML template rendering in Python — no build step, no template files, and native HTML awareness out of the box.
6
6
 
7
7
  ```python
8
8
  from fragments import loader # isort: skip
@@ -26,6 +26,12 @@ async def index() -> str:
26
26
  </>
27
27
  ```
28
28
 
29
+ ## IDE Support
30
+
31
+ Type checking, completions, hover docs, go-to-definition, rename, and semantic highlighting — all working inside fragment syntax.
32
+
33
+ ![VS Code completions demo](docs/assets/vscode.gif)
34
+
29
35
  ## Installation
30
36
 
31
37
  ```bash
@@ -39,3 +45,7 @@ from fragments import loader # isort: skip
39
45
  ```
40
46
 
41
47
  Any `.py` file containing `<>` is transpiled automatically. Nothing else to configure.
48
+
49
+ ## Feedback and feature requests
50
+
51
+ Bug reports and feature requests are welcome via [GitHub Issues](https://github.com/TheRunningAlgorithm2/python-fragments/issues). The project is not currently accepting code contributions.
@@ -1,7 +1,7 @@
1
1
  from dataclasses import dataclass, field
2
2
  from typing import Sequence
3
3
 
4
- type ASTHTMLChild = ASTHTMLElement | ASTHTMLComment | ASTHTMLText | ASTInterpolation | ASTComponent | ASTControlNode
4
+ type ASTHTMLChild = ASTHTMLElement | ASTHTMLComment | ASTHTMLText | ASTInterpolation | ASTComponent | ASTControlNode | ASTDoctype | ASTChildrenSlot
5
5
 
6
6
 
7
7
  @dataclass(slots=True)
@@ -15,7 +15,7 @@ class ASTModule:
15
15
  transpiled_start: int = field(init=False)
16
16
  transpiled_end: int = field(init=False)
17
17
 
18
- __template__: str = "from fragments.html.elements import el, sequence, comment\n{}"
18
+ __template__: str = "from fragments.html.elements import el, _sequence, comment\n{}"
19
19
 
20
20
  def transpile(self, transpiled_start: int = 0) -> None:
21
21
  """Build transpiled outputs for the module."""
@@ -83,18 +83,15 @@ class ASTFragment:
83
83
  transpiled_start: int = field(init=False)
84
84
  transpiled_end: int = field(init=False)
85
85
 
86
- __template__: str = """sequence([{}])"""
87
-
88
86
  def transpile(self, transpiled_start: int) -> None:
89
87
  self.transpiled_start = transpiled_start
90
- transpiled_start += 10
91
88
  for child in self.children:
92
89
  child.transpile(transpiled_start)
93
90
  transpiled_start = child.transpiled_end + 1
94
- if len(self.children) > 0:
91
+ if self.children:
95
92
  transpiled_start -= 1
96
93
 
97
- self.transpiled_content = self.__template__.format(",".join(child.transpiled_content for child in self.children))
94
+ self.transpiled_content = "+".join(child.transpiled_content for child in self.children) if self.children else '""'
98
95
  self.transpiled_end = self.transpiled_start + len(self.transpiled_content)
99
96
 
100
97
  def map_offset(self, offset: int) -> int | None:
@@ -126,26 +123,28 @@ class ASTHTMLElement:
126
123
  transpiled_start: int = field(init=False)
127
124
  transpiled_end: int = field(init=False)
128
125
 
129
- __element_template__: str = """el("{}",[{}],oneline={},attributes={})"""
126
+ __element_template__: str = """el("{}",{},oneline={},attributes={})"""
130
127
 
131
128
  def transpile(self, transpiled_start: int) -> None:
132
129
  self.transpiled_start = transpiled_start
133
- transpiled_start = transpiled_start + len(self.name) + 7
130
+ transpiled_start = transpiled_start + len(self.name) + 6 # el("name",
134
131
  for child in self.children:
135
132
  child.transpile(transpiled_start)
136
133
  transpiled_start = child.transpiled_end + 1
137
- if len(self.children) > 0:
134
+ if self.children:
138
135
  transpiled_start -= 1
139
- children = ",".join(child.transpiled_content for child in self.children)
136
+ children_expr = "+".join(child.transpiled_content for child in self.children) if self.children else '""'
137
+ if not self.children:
138
+ transpiled_start += 2 # ""
140
139
  oneline_offset = len(str(self.one_line))
141
- transpiled_start += 10 + oneline_offset + 12 + 1 # ],oneline= + oneline + ,attributes= + {
140
+ transpiled_start += 9 + oneline_offset + 12 + 1 # ,oneline= + oneline + ,attributes= + {
142
141
  for attribute in self.attributes.values():
143
142
  attribute.transpile(transpiled_start)
144
143
  transpiled_start = attribute.transpiled_end + 1
145
144
  transpiled_start -= 1
146
145
 
147
146
  attributes = "{" + ",".join(attribute.transpiled_content for attribute in self.attributes.values()) + "}"
148
- self.transpiled_content = self.__element_template__.format(self.name, children, self.one_line, attributes)
147
+ self.transpiled_content = self.__element_template__.format(self.name, children_expr, self.one_line, attributes)
149
148
  self.transpiled_end = self.transpiled_start + len(self.transpiled_content)
150
149
 
151
150
  def map_offset(self, offset: int) -> int | None:
@@ -184,18 +183,18 @@ class ASTControlNode[T: (ASTHTMLElement, ASTComponent)]:
184
183
  transpiled_start: int = field(init=False)
185
184
  transpiled_end: int = field(init=False)
186
185
 
187
- __for_template__: str = "sequence([{} for {}])"
188
- __if_template__: str = "{} if {} else ''"
186
+ __for_template__: str = "_sequence([{} for {}])"
187
+ __if_template__: str = "({} if {} else '')"
189
188
 
190
189
  def transpile(self, transpiled_start: int) -> None:
191
190
  self.transpiled_start = transpiled_start
192
191
  if self.for_interpolation is not None:
193
- self.child.transpile(transpiled_start + 10) # sequence([
192
+ self.child.transpile(transpiled_start + 11) # _sequence([
194
193
  self.for_interpolation.transpile(self.child.transpiled_end + 5) # child + for
195
194
  self.transpiled_content = self.__for_template__.format(self.child.transpiled_content, self.for_interpolation.transpiled_content)
196
195
  elif self.if_interpolation is not None:
197
- self.child.transpile(transpiled_start)
198
- self.if_interpolation.transpile(self.child.transpiled_end + 4) # child + if
196
+ self.child.transpile(transpiled_start + 1) # ( before child
197
+ self.if_interpolation.transpile(self.child.transpiled_end + 4) # " if "
199
198
  self.transpiled_content = self.__if_template__.format(self.child.transpiled_content, self.if_interpolation.transpiled_content)
200
199
  self.transpiled_end = self.transpiled_start + len(self.transpiled_content)
201
200
 
@@ -250,25 +249,27 @@ class ASTComponent:
250
249
  transpiled_start: int = field(init=False)
251
250
  transpiled_end: int = field(init=False)
252
251
 
253
- __template__: str = """{}([{}],{})"""
252
+ __template__: str = """{}({},{})"""
254
253
 
255
254
  def transpile(self, transpiled_start: int) -> None:
256
255
  self.transpiled_start = transpiled_start
257
256
  self.name.transpile(self.transpiled_start)
258
- transpiled_start = self.name.transpiled_end + 2
257
+ transpiled_start = self.name.transpiled_end + 1 # (
259
258
  for child in self.children:
260
259
  child.transpile(transpiled_start)
261
- transpiled_start = child.transpiled_end + 1
260
+ transpiled_start = child.transpiled_end + 1 # + between children
262
261
  if self.children:
263
262
  transpiled_start -= 1
264
- children = ",".join(child.transpiled_content for child in self.children)
263
+ children = "+".join(child.transpiled_content for child in self.children) if self.children else '""'
264
+ if not self.children:
265
+ transpiled_start += 2 # ""
265
266
 
266
267
  if len(self.arguments) == 0:
267
268
  self.transpiled_content = self.__template__.format(self.name.transpiled_content, children, "")
268
269
  self.transpiled_end = self.transpiled_start + len(self.transpiled_content)
269
270
  return
270
271
 
271
- transpiled_start += 2
272
+ transpiled_start += 1 # , before arguments
272
273
  for attribute in self.arguments.values():
273
274
  attribute.transpile(transpiled_start)
274
275
  transpiled_start = attribute.transpiled_end + 1
@@ -392,6 +393,27 @@ class ASTComponentArgument:
392
393
  return None
393
394
 
394
395
 
396
+ @dataclass(slots=True)
397
+ class ASTDoctype:
398
+ source_start: int = field(compare=False)
399
+ source_end: int = field(compare=False)
400
+
401
+ transpiled_content: str = field(init=False)
402
+ transpiled_start: int = field(init=False)
403
+ transpiled_end: int = field(init=False)
404
+
405
+ def transpile(self, transpiled_start: int) -> None:
406
+ self.transpiled_start = transpiled_start
407
+ self.transpiled_content = '"<!DOCTYPE html>"'
408
+ self.transpiled_end = self.transpiled_start + len(self.transpiled_content)
409
+
410
+ def map_offset(self, offset: int) -> None:
411
+ return None
412
+
413
+ def unmap_offset(self, offset: int) -> None:
414
+ return None
415
+
416
+
395
417
  @dataclass(slots=True)
396
418
  class ASTHTMLComment:
397
419
  source_start: int = field(compare=False)
@@ -525,3 +547,24 @@ class ASTInterpolation:
525
547
  return self.source_start + specific_offset + 2 + self.leading_whitespace
526
548
 
527
549
  return None
550
+
551
+
552
+ @dataclass(slots=True)
553
+ class ASTChildrenSlot:
554
+ source_start: int = field(compare=False)
555
+ source_end: int = field(compare=False)
556
+
557
+ transpiled_content: str = field(init=False)
558
+ transpiled_start: int = field(init=False)
559
+ transpiled_end: int = field(init=False)
560
+
561
+ def transpile(self, transpiled_start: int) -> None:
562
+ self.transpiled_start = transpiled_start
563
+ self.transpiled_content = "children"
564
+ self.transpiled_end = transpiled_start + len("children")
565
+
566
+ def map_offset(self, offset: int) -> None:
567
+ return None
568
+
569
+ def unmap_offset(self, offset: int) -> None:
570
+ return None
@@ -2,10 +2,12 @@ from re import Match
2
2
  import re
3
3
 
4
4
  from fragments.ast_nodes import (
5
+ ASTChildrenSlot,
5
6
  ASTComponent,
6
7
  ASTComponentArgument,
7
8
  ASTComponentName,
8
9
  ASTControlNode,
10
+ ASTDoctype,
9
11
  ASTFragment,
10
12
  ASTHTMLAttribute,
11
13
  ASTHTMLComment,
@@ -21,7 +23,6 @@ from fragments.source import Source
21
23
  HTML_IDENTIFIER = r"[a-zA-Z][a-zA-Z0-9_:.-]*"
22
24
  HTML_ATTRIBUTE_NAME = r"[a-zA-Z:-_][a-zA-Z0-9_:.-]*"
23
25
  HTML_TEXT = r"([\s\S]*?)(?=<|{{)"
24
- CHILDREN_META_COMPONENT = r"<[\S]*Children\.\.\.[\S]*/>"
25
26
 
26
27
 
27
28
  class ParsingError(Exception):
@@ -102,7 +103,9 @@ def expect_fragment(source: Source) -> tuple[Source, ASTFragment]:
102
103
 
103
104
  children: list[ASTHTMLChild] = []
104
105
  while not source.remaining().startswith("</>"):
105
- source, _ = source.eat_whitespace()
106
+ source_after_whitespace, _ = source.eat_whitespace()
107
+ if source_after_whitespace.remaining().startswith("<"):
108
+ source = source_after_whitespace
106
109
  if source.remaining().startswith("</>"):
107
110
  break
108
111
  source, child = expect_child(source)
@@ -115,12 +118,26 @@ def expect_fragment(source: Source) -> tuple[Source, ASTFragment]:
115
118
  return source, ASTFragment(source_start, source_end, children)
116
119
 
117
120
 
121
+ def expect_children_slot(source: Source) -> tuple[Source, ASTChildrenSlot]:
122
+ source_start = source.offset
123
+ source = expect_string(source, "<Children... />")
124
+ return source, ASTChildrenSlot(source_start, source.offset)
125
+
126
+
118
127
  def expect_child(source: Source) -> tuple[Source, ASTHTMLChild]:
119
128
  """Any HTML / functional block that might appear as part of the fragment."""
129
+ if source.remaining().startswith("<!DOCTYPE html>"):
130
+ source, doctype = expect_doctype(source)
131
+ return source, doctype
132
+
120
133
  if source.remaining().startswith("<!--"):
121
134
  source, html_comment = expect_html_comment(source)
122
135
  return source, html_comment
123
136
 
137
+ if source.remaining().startswith("<Children... />"):
138
+ source, children_slot = expect_children_slot(source)
139
+ return source, children_slot
140
+
124
141
  if source.start_matches(r"<[A-Z]"):
125
142
  source, component = expect_component(source)
126
143
  return source, component
@@ -192,6 +209,12 @@ def expect_component_name(source: Source) -> tuple[Source, ASTComponentName]:
192
209
  return source, ASTComponentName(source_start, source.offset, name)
193
210
 
194
211
 
212
+ def expect_doctype(source: Source) -> tuple[Source, ASTDoctype]:
213
+ source_start = source.offset
214
+ source = expect_string(source, "<!DOCTYPE html>")
215
+ return source, ASTDoctype(source_start, source.offset)
216
+
217
+
195
218
  def expect_html_comment(source: Source) -> tuple[Source, ASTHTMLComment]:
196
219
  source_start = source.offset
197
220
  source = expect_string(source, "<!--")
@@ -270,7 +293,7 @@ def expect_children(source: Source) -> tuple[Source, list[ASTHTMLChild]]:
270
293
  source, child = expect_child(source)
271
294
  children.append(child)
272
295
  source_after_whitespace, _ = source.eat_whitespace()
273
- if not source_after_whitespace.remaining().startswith("{{"):
296
+ if source_after_whitespace.remaining().startswith("<"):
274
297
  source = source_after_whitespace
275
298
  return source, children
276
299
 
@@ -1,11 +1,11 @@
1
1
  import json
2
2
  from typing import Any
3
- from fragments.types import Children
3
+ from fragments.types import Children, Stringable
4
4
  import html
5
5
 
6
6
 
7
- def sequence(children: Children) -> str:
8
- return "".join(str(child) for child in children)
7
+ def _sequence(items: list[str | Stringable]) -> str:
8
+ return "".join(str(item) for item in items)
9
9
 
10
10
 
11
11
  def el(
@@ -26,9 +26,7 @@ def el(
26
26
  if oneline:
27
27
  return f"""<{tag_contents_string} />"""
28
28
 
29
- children_string = sequence(children)
30
-
31
- return f"""<{tag_contents_string}>{children_string}</{name}>"""
29
+ return f"""<{tag_contents_string}>{children}</{name}>"""
32
30
 
33
31
 
34
32
  def comment(content: str) -> str:
@@ -5,5 +5,4 @@ class Stringable(Protocol):
5
5
  def __str__(self) -> str: ...
6
6
 
7
7
 
8
- type Child = str | Stringable
9
- type Children = list[Child]
8
+ type Children = str
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-fragments"
7
- version = "0.28"
7
+ version = "0.31"
8
8
  description = "Modern HTML template rendering in Python"
9
9
  authors = [{ name = "The Running Algorithm", email = "services@therunningalgorithm.info" }]
10
10
  readme = "README.md"
11
- license = { text = "Proprietary" }
11
+ license = { file = "LICENSE" }
12
12
  requires-python = ">=3.12"
13
13
  dependencies = []
14
14
 
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-fragments
3
+ Version: 0.31
4
+ Summary: Modern HTML template rendering in Python
5
+ Author-email: The Running Algorithm <services@therunningalgorithm.info>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 The Running Algorithm
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Requires-Python: >=3.12
29
+ Description-Content-Type: text/markdown
30
+ License-File: LICENSE
31
+ Provides-Extra: lsp
32
+ Requires-Dist: basedpyright>=1.39.0; extra == "lsp"
33
+ Requires-Dist: lsprotocol>=2025.0.0; extra == "lsp"
34
+ Provides-Extra: dev
35
+ Requires-Dist: pytest>=8.4.1; extra == "dev"
36
+ Dynamic: license-file
37
+
38
+ # Python Fragments
39
+
40
+ > **This package is in early development and not yet stable. The API may change without notice between releases.**
41
+
42
+ Modern HTML template rendering in Python — no build step, no template files, and native HTML awareness out of the box.
43
+
44
+ ```python
45
+ from fragments import loader # isort: skip
46
+
47
+ from fastapi import FastAPI
48
+ from fastapi.responses import HTMLResponse
49
+ from components import Layout, PostCard
50
+
51
+ app = FastAPI()
52
+
53
+ POSTS = [...]
54
+
55
+ @app.get("/", response_class=HTMLResponse)
56
+ async def index() -> str:
57
+ published = [p for p in POSTS if p.published]
58
+ return <>
59
+ <Layout title="My Blog">
60
+ <h1>Latest Posts</h1>
61
+ <PostCard for={{ post in published }} post={{ post }} />
62
+ </Layout>
63
+ </>
64
+ ```
65
+
66
+ ## IDE Support
67
+
68
+ Type checking, completions, hover docs, go-to-definition, rename, and semantic highlighting — all working inside fragment syntax.
69
+
70
+ ![VS Code completions demo](docs/assets/vscode.gif)
71
+
72
+ ## Installation
73
+
74
+ ```bash
75
+ pip install python-fragments
76
+ ```
77
+
78
+ Register the loader at your application's entry point, before importing any modules that contain fragments:
79
+
80
+ ```python
81
+ from fragments import loader # isort: skip
82
+ ```
83
+
84
+ Any `.py` file containing `<>` is transpiled automatically. Nothing else to configure.
85
+
86
+ ## Feedback and feature requests
87
+
88
+ Bug reports and feature requests are welcome via [GitHub Issues](https://github.com/TheRunningAlgorithm2/python-fragments/issues). The project is not currently accepting code contributions.
@@ -1,3 +1,4 @@
1
+ LICENSE
1
2
  README.md
2
3
  pyproject.toml
3
4
  fragments/__init__.py
@@ -40,6 +41,7 @@ python_fragments.egg-info/dependency_links.txt
40
41
  python_fragments.egg-info/entry_points.txt
41
42
  python_fragments.egg-info/requires.txt
42
43
  python_fragments.egg-info/top_level.txt
44
+ tests/test_end_to_end.py
43
45
  tests/test_grammar.py
44
46
  tests/test_html_elements.py
45
47
  tests/test_source_map.py
@@ -0,0 +1,158 @@
1
+ from fragments.grammar import expect_module
2
+ from fragments.source import Source
3
+
4
+
5
+ def render(source_str: str, **variables: object) -> str:
6
+ """Transpile a fragment source string, execute it, and return the result."""
7
+ source = Source.from_string(f"result = {source_str.strip()}")
8
+ _, module = expect_module(source)
9
+ module.transpile(0)
10
+ namespace: dict[str, object] = dict(variables)
11
+ exec(module.transpiled_content, namespace)
12
+ return str(namespace["result"])
13
+
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Basic elements
17
+ # ---------------------------------------------------------------------------
18
+
19
+
20
+ def test_single_element():
21
+ assert render("<><h1>Hello</h1></>") == "<h1>Hello</h1>"
22
+
23
+
24
+ def test_multiple_siblings():
25
+ assert render("<><h1>Title</h1><p>Body</p></>") == "<h1>Title</h1><p>Body</p>"
26
+
27
+
28
+ def test_nested_elements():
29
+ assert render("<><div><p>text</p></div></>") == "<div><p>text</p></div>"
30
+
31
+
32
+ def test_self_closing_element():
33
+ assert render("<><br /></>") == "<br />"
34
+
35
+
36
+ def test_empty_fragment():
37
+ assert render('<></>') == ""
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Interpolation
42
+ # ---------------------------------------------------------------------------
43
+
44
+
45
+ def test_interpolation():
46
+ assert render("<><p>{{ name }}</p></>", name="World") == "<p>World</p>"
47
+
48
+
49
+ def test_multiple_interpolations():
50
+ assert render("<><p>{{ a }} and {{ b }}</p></>", a="foo", b="bar") == "<p>foo and bar</p>"
51
+
52
+
53
+ def test_text_space_before_element():
54
+ assert render("<>Icon: <i></i></>") == "Icon: <i></i>"
55
+
56
+
57
+ def test_text_space_after_element():
58
+ assert render("<><i></i> text</>") == "<i></i> text"
59
+
60
+
61
+ def test_text_space_around_element():
62
+ assert render("<>before <i>em</i> after</>") == "before <i>em</i> after"
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Control flow — if
67
+ # ---------------------------------------------------------------------------
68
+
69
+
70
+ def test_if_true():
71
+ assert render("<><p if={{ cond }}>yes</p></>", cond=True) == "<p>yes</p>"
72
+
73
+
74
+ def test_if_false():
75
+ assert render("<><p if={{ cond }}>yes</p></>", cond=False) == ""
76
+
77
+
78
+ def test_if_true_with_sibling():
79
+ result = render("<><p if={{ cond }}>yes</p><p>always</p></>", cond=True)
80
+ assert result == "<p>yes</p><p>always</p>"
81
+
82
+
83
+ def test_if_false_with_sibling():
84
+ result = render("<><p if={{ cond }}>yes</p><p>always</p></>", cond=False)
85
+ assert result == "<p>always</p>"
86
+
87
+
88
+ def test_if_true_with_preceding_sibling():
89
+ result = render("<><p>always</p><p if={{ cond }}>maybe</p></>", cond=True)
90
+ assert result == "<p>always</p><p>maybe</p>"
91
+
92
+
93
+ def test_if_false_with_preceding_sibling():
94
+ result = render("<><p>always</p><p if={{ cond }}>maybe</p></>", cond=False)
95
+ assert result == "<p>always</p>"
96
+
97
+
98
+ def test_if_between_siblings():
99
+ result = render("<><p>before</p><p if={{ cond }}>maybe</p><p>after</p></>", cond=True)
100
+ assert result == "<p>before</p><p>maybe</p><p>after</p>"
101
+
102
+ result = render("<><p>before</p><p if={{ cond }}>maybe</p><p>after</p></>", cond=False)
103
+ assert result == "<p>before</p><p>after</p>"
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # Control flow — for
108
+ # ---------------------------------------------------------------------------
109
+
110
+
111
+ def test_for_loop():
112
+ result = render("<><li for={{ item in items }}>{{ item }}</li></>", items=["a", "b", "c"])
113
+ assert result == "<li>a</li><li>b</li><li>c</li>"
114
+
115
+
116
+ def test_for_loop_with_sibling():
117
+ result = render("<><h1>List</h1><li for={{ item in items }}>{{ item }}</li></>", items=["x", "y"])
118
+ assert result == "<h1>List</h1><li>x</li><li>y</li>"
119
+
120
+
121
+ def test_for_loop_empty():
122
+ assert render("<><li for={{ item in items }}>{{ item }}</li></>", items=[]) == ""
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # Components
127
+ # ---------------------------------------------------------------------------
128
+
129
+
130
+ def test_component_no_children():
131
+ def Badge(children: str, label: str) -> str:
132
+ return f"<span>{label}</span>"
133
+
134
+ result = render("<><Badge label=\"hi\" /></>", Badge=Badge)
135
+ assert result == "<span>hi</span>"
136
+
137
+
138
+ def test_component_with_children():
139
+ from fragments.types import Children
140
+
141
+ def Wrapper(children: Children) -> str:
142
+ return f"<div>{children}</div>"
143
+
144
+ result = render("<><Wrapper><p>content</p></Wrapper></>", Wrapper=Wrapper)
145
+ assert result == "<div><p>content</p></div>"
146
+
147
+
148
+ def test_component_children_pre_joined():
149
+ from fragments.types import Children
150
+
151
+ def Wrapper(children: Children) -> str:
152
+ return f"<div>{children}</div>"
153
+
154
+ result = render(
155
+ "<><Wrapper><p>one</p><p>two</p></Wrapper></>",
156
+ Wrapper=Wrapper,
157
+ )
158
+ assert result == "<div><p>one</p><p>two</p></div>"
@@ -2,10 +2,12 @@ import pytest
2
2
 
3
3
  from fragments import grammar
4
4
  from fragments.ast_nodes import (
5
+ ASTChildrenSlot,
5
6
  ASTComponent,
6
7
  ASTComponentArgument,
7
8
  ASTComponentName,
8
9
  ASTControlNode,
10
+ ASTDoctype,
9
11
  ASTFragment,
10
12
  ASTHTMLAttribute,
11
13
  ASTHTMLComment,
@@ -23,6 +25,47 @@ def _transpiled(fragment: ASTFragment) -> ASTFragment:
23
25
  return fragment
24
26
 
25
27
 
28
+ # ---------------------------------------------------------------------------
29
+ # Doctype
30
+ # ---------------------------------------------------------------------------
31
+
32
+
33
+ def test_doctype_standalone():
34
+ source = Source.from_string("<><!DOCTYPE html></>")
35
+ source, fragment = grammar.expect_fragment(source)
36
+ assert source.at_end()
37
+ assert _transpiled(fragment) == _transpiled(ASTFragment(-1, -1, [
38
+ ASTDoctype(-1, -1)
39
+ ]))
40
+
41
+
42
+ def test_doctype_transpiles_to_literal_string():
43
+ source = Source.from_string("<><!DOCTYPE html></>")
44
+ _, fragment = grammar.expect_fragment(source)
45
+ fragment.transpile(0)
46
+ doctype = fragment.children[0]
47
+ assert isinstance(doctype, ASTDoctype)
48
+ assert doctype.transpiled_content == '"<!DOCTYPE html>"'
49
+
50
+
51
+ def test_doctype_followed_by_element():
52
+ source = Source.from_string("<><!DOCTYPE html><html></html></>")
53
+ source, fragment = grammar.expect_fragment(source)
54
+ assert source.at_end()
55
+ assert _transpiled(fragment) == _transpiled(ASTFragment(-1, -1, [
56
+ ASTDoctype(-1, -1),
57
+ ASTHTMLElement(-1, -1, "html", {}, [], False),
58
+ ]))
59
+
60
+
61
+ def test_doctype_not_confused_with_comment():
62
+ source = Source.from_string("<><!DOCTYPE html><!-- a note --></>")
63
+ source, fragment = grammar.expect_fragment(source)
64
+ assert source.at_end()
65
+ assert isinstance(fragment.children[0], ASTDoctype)
66
+ assert isinstance(fragment.children[1], ASTHTMLComment)
67
+
68
+
26
69
  # ---------------------------------------------------------------------------
27
70
  # Comments
28
71
  # ---------------------------------------------------------------------------
@@ -325,6 +368,53 @@ def test_lowercase_not_parsed_as_component():
325
368
  assert isinstance(fragment.children[0], ASTHTMLElement)
326
369
 
327
370
 
371
+ # ---------------------------------------------------------------------------
372
+ # Children slot
373
+ # ---------------------------------------------------------------------------
374
+
375
+
376
+ def test_children_slot_parses():
377
+ source = Source.from_string("<><Children... /></>")
378
+ source, fragment = grammar.expect_fragment(source)
379
+ assert source.at_end()
380
+ assert _transpiled(fragment) == _transpiled(ASTFragment(-1, -1, [
381
+ ASTChildrenSlot(-1, -1)
382
+ ]))
383
+
384
+
385
+ def test_children_slot_transpiles_to_children():
386
+ source = Source.from_string("<><Children... /></>")
387
+ _, fragment = grammar.expect_fragment(source)
388
+ fragment.transpile(0)
389
+ slot = fragment.children[0]
390
+ assert isinstance(slot, ASTChildrenSlot)
391
+ assert slot.transpiled_content == "children"
392
+
393
+
394
+ def test_children_slot_multiple_in_one_fragment():
395
+ source = Source.from_string("<><Children... /><Children... /></>")
396
+ source, fragment = grammar.expect_fragment(source)
397
+ assert source.at_end()
398
+ assert len(fragment.children) == 2
399
+ assert all(isinstance(child, ASTChildrenSlot) for child in fragment.children)
400
+
401
+
402
+ def test_children_slot_not_confused_with_component():
403
+ source = Source.from_string("<><Children /></>")
404
+ source, fragment = grammar.expect_fragment(source)
405
+ assert source.at_end()
406
+ assert isinstance(fragment.children[0], ASTComponent)
407
+
408
+
409
+ def test_children_slot_inside_element():
410
+ source = Source.from_string("<><div><Children... /></div></>")
411
+ source, fragment = grammar.expect_fragment(source)
412
+ assert source.at_end()
413
+ element = fragment.children[0]
414
+ assert isinstance(element, ASTHTMLElement)
415
+ assert isinstance(element.children[0], ASTChildrenSlot)
416
+
417
+
328
418
  # ---------------------------------------------------------------------------
329
419
  # HTML text
330
420
  # ---------------------------------------------------------------------------
@@ -22,10 +22,10 @@ def test_className_string_passthrough():
22
22
 
23
23
 
24
24
  def test_el_className_list_attribute():
25
- result = el("div", ["content"], False, {"className": ["foo", "bar"]})
25
+ result = el("div", "content", False, {"className": ["foo", "bar"]})
26
26
  assert result == '<div class="foo bar">content</div>'
27
27
 
28
28
 
29
29
  def test_el_className_empty_list():
30
- result = el("div", [], True, {"className": []})
30
+ result = el("div", "", True, {"className": []})
31
31
  assert result == '<div class="" />'
@@ -1,7 +1,7 @@
1
1
  from fragments.lsp.file_state import FileState
2
2
  from lsprotocol import types
3
3
 
4
- IMPORT_PREFIX = "from fragments.html.elements import el, sequence, comment\n"
4
+ IMPORT_PREFIX = "from fragments.html.elements import el, _sequence, comment\n"
5
5
  IMPORT_PREFIX_LEN = len(IMPORT_PREFIX)
6
6
 
7
7
 
@@ -1,55 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: python-fragments
3
- Version: 0.28
4
- Summary: Modern HTML template rendering in Python
5
- Author-email: The Running Algorithm <services@therunningalgorithm.info>
6
- License: Proprietary
7
- Requires-Python: >=3.12
8
- Description-Content-Type: text/markdown
9
- Provides-Extra: lsp
10
- Requires-Dist: basedpyright>=1.39.0; extra == "lsp"
11
- Requires-Dist: lsprotocol>=2025.0.0; extra == "lsp"
12
- Provides-Extra: dev
13
- Requires-Dist: pytest>=8.4.1; extra == "dev"
14
-
15
- # Python Fragments
16
-
17
- > **This package is in early development and not yet stable. The API may change without notice between releases.**
18
-
19
- Production-ready, modern HTML template rendering in Python — no build step, no template files, and native HTML awareness out of the box.
20
-
21
- ```python
22
- from fragments import loader # isort: skip
23
-
24
- from fastapi import FastAPI
25
- from fastapi.responses import HTMLResponse
26
- from components import Layout, PostCard
27
-
28
- app = FastAPI()
29
-
30
- POSTS = [...]
31
-
32
- @app.get("/", response_class=HTMLResponse)
33
- async def index() -> str:
34
- published = [p for p in POSTS if p.published]
35
- return <>
36
- <Layout title="My Blog">
37
- <h1>Latest Posts</h1>
38
- <PostCard for={{ post in published }} post={{ post }} />
39
- </Layout>
40
- </>
41
- ```
42
-
43
- ## Installation
44
-
45
- ```bash
46
- pip install python-fragments
47
- ```
48
-
49
- Register the loader at your application's entry point, before importing any modules that contain fragments:
50
-
51
- ```python
52
- from fragments import loader # isort: skip
53
- ```
54
-
55
- Any `.py` file containing `<>` is transpiled automatically. Nothing else to configure.
@@ -1,55 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: python-fragments
3
- Version: 0.28
4
- Summary: Modern HTML template rendering in Python
5
- Author-email: The Running Algorithm <services@therunningalgorithm.info>
6
- License: Proprietary
7
- Requires-Python: >=3.12
8
- Description-Content-Type: text/markdown
9
- Provides-Extra: lsp
10
- Requires-Dist: basedpyright>=1.39.0; extra == "lsp"
11
- Requires-Dist: lsprotocol>=2025.0.0; extra == "lsp"
12
- Provides-Extra: dev
13
- Requires-Dist: pytest>=8.4.1; extra == "dev"
14
-
15
- # Python Fragments
16
-
17
- > **This package is in early development and not yet stable. The API may change without notice between releases.**
18
-
19
- Production-ready, modern HTML template rendering in Python — no build step, no template files, and native HTML awareness out of the box.
20
-
21
- ```python
22
- from fragments import loader # isort: skip
23
-
24
- from fastapi import FastAPI
25
- from fastapi.responses import HTMLResponse
26
- from components import Layout, PostCard
27
-
28
- app = FastAPI()
29
-
30
- POSTS = [...]
31
-
32
- @app.get("/", response_class=HTMLResponse)
33
- async def index() -> str:
34
- published = [p for p in POSTS if p.published]
35
- return <>
36
- <Layout title="My Blog">
37
- <h1>Latest Posts</h1>
38
- <PostCard for={{ post in published }} post={{ post }} />
39
- </Layout>
40
- </>
41
- ```
42
-
43
- ## Installation
44
-
45
- ```bash
46
- pip install python-fragments
47
- ```
48
-
49
- Register the loader at your application's entry point, before importing any modules that contain fragments:
50
-
51
- ```python
52
- from fragments import loader # isort: skip
53
- ```
54
-
55
- Any `.py` file containing `<>` is transpiled automatically. Nothing else to configure.