galaga-marimo 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.
- galaga_marimo-0.1.0/.gitignore +14 -0
- galaga_marimo-0.1.0/PKG-INFO +42 -0
- galaga_marimo-0.1.0/README.md +18 -0
- galaga_marimo-0.1.0/galaga_marimo/__init__.py +28 -0
- galaga_marimo-0.1.0/galaga_marimo/api.py +253 -0
- galaga_marimo-0.1.0/galaga_marimo/renderer.py +137 -0
- galaga_marimo-0.1.0/pyproject.toml +33 -0
- galaga_marimo-0.1.0/tests/test_galaga_marimo.py +679 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: galaga-marimo
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Marimo notebook helpers for geometric algebra — t-string powered LaTeX rendering
|
|
5
|
+
Project-URL: Homepage, https://github.com/edouardp/galaga
|
|
6
|
+
Project-URL: Repository, https://github.com/edouardp/galaga
|
|
7
|
+
Project-URL: Issues, https://github.com/edouardp/galaga/issues
|
|
8
|
+
Author: Edouard Poor
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: geometric-algebra,latex,marimo,notebook,t-string
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Framework :: Jupyter
|
|
13
|
+
Classifier: Intended Audience :: Education
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
20
|
+
Requires-Python: >=3.14
|
|
21
|
+
Requires-Dist: galaga>=0.1.0
|
|
22
|
+
Requires-Dist: marimo>=0.21.1
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# galaga-marimo
|
|
26
|
+
|
|
27
|
+
Marimo notebook helpers for geometric algebra — t-string powered LaTeX rendering.
|
|
28
|
+
|
|
29
|
+
Requires Python 3.14+ for t-string support.
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
import galaga_marimo as gm
|
|
35
|
+
|
|
36
|
+
gm.md(t"""
|
|
37
|
+
# Example
|
|
38
|
+
|
|
39
|
+
Rotor: {R}
|
|
40
|
+
Vector: {v}
|
|
41
|
+
""")
|
|
42
|
+
```
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# galaga-marimo
|
|
2
|
+
|
|
3
|
+
Marimo notebook helpers for geometric algebra — t-string powered LaTeX rendering.
|
|
4
|
+
|
|
5
|
+
Requires Python 3.14+ for t-string support.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
import galaga_marimo as gm
|
|
11
|
+
|
|
12
|
+
gm.md(t"""
|
|
13
|
+
# Example
|
|
14
|
+
|
|
15
|
+
Rotor: {R}
|
|
16
|
+
Vector: {v}
|
|
17
|
+
""")
|
|
18
|
+
```
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""galaga_marimo — Marimo notebook helpers for geometric algebra.
|
|
2
|
+
|
|
3
|
+
Provides t-string powered markdown rendering that automatically
|
|
4
|
+
converts GA objects to LaTeX in marimo notebooks.
|
|
5
|
+
|
|
6
|
+
Requires Python 3.14+ for t-string support.
|
|
7
|
+
|
|
8
|
+
Usage::
|
|
9
|
+
|
|
10
|
+
import galaga_marimo as gm
|
|
11
|
+
|
|
12
|
+
gm.md(t"Rotor: {R}")
|
|
13
|
+
gm.md(t"Result: {expr:block}")
|
|
14
|
+
|
|
15
|
+
# For loops and programmatic content:
|
|
16
|
+
with gm.doc() as d:
|
|
17
|
+
d.md(t"# Results")
|
|
18
|
+
for name, e in exprs:
|
|
19
|
+
d.md(t"**{name}:** {e} = {e.eval()}")
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from galaga_marimo.api import md, inline, block, latex, block_latex, text, doc, Doc
|
|
23
|
+
from galaga_marimo.renderer import render_template
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"md", "inline", "block", "latex", "block_latex", "text",
|
|
27
|
+
"doc", "Doc", "render_template",
|
|
28
|
+
]
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Public API for galaga_marimo.
|
|
2
|
+
|
|
3
|
+
Provides md(), inline(), block(), and explicit wrappers.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from string.templatelib import Template
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from galaga_marimo.renderer import render_template, render_value, RenderKind, _get_latex, _has_latex
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def md(template: Template) -> Any:
|
|
15
|
+
"""Render a t-string template as marimo markdown.
|
|
16
|
+
|
|
17
|
+
GA objects with a .latex() method are automatically rendered as
|
|
18
|
+
inline LaTeX. Use format specs to override:
|
|
19
|
+
|
|
20
|
+
gm.md(t"Vector: {v}") # auto inline LaTeX
|
|
21
|
+
gm.md(t"Equation: {expr:block}") # display block LaTeX
|
|
22
|
+
gm.md(t"Debug: {mv!r}") # repr as text
|
|
23
|
+
gm.md(t"Value: {x:.3f}") # numeric formatting
|
|
24
|
+
|
|
25
|
+
Returns a marimo Html object if marimo is available, otherwise
|
|
26
|
+
returns the rendered markdown string.
|
|
27
|
+
"""
|
|
28
|
+
markdown = render_template(template)
|
|
29
|
+
import textwrap
|
|
30
|
+
markdown = textwrap.dedent(markdown).strip()
|
|
31
|
+
try:
|
|
32
|
+
import marimo as mo
|
|
33
|
+
return mo.md(markdown)
|
|
34
|
+
except ImportError:
|
|
35
|
+
return markdown
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def inline(template: Template) -> Any:
|
|
39
|
+
"""Render a t-string as a single inline LaTeX expression.
|
|
40
|
+
|
|
41
|
+
All interpolations with .latex() are joined; the whole result
|
|
42
|
+
is wrapped in $...$.
|
|
43
|
+
|
|
44
|
+
gm.inline(t"R = {R}") → $R = e_{12}$
|
|
45
|
+
"""
|
|
46
|
+
parts: list[str] = []
|
|
47
|
+
for item in template:
|
|
48
|
+
if isinstance(item, str):
|
|
49
|
+
parts.append(item)
|
|
50
|
+
else:
|
|
51
|
+
from string.templatelib import Interpolation
|
|
52
|
+
if isinstance(item, Interpolation) and _has_latex(item.value):
|
|
53
|
+
parts.append(_get_latex(item.value))
|
|
54
|
+
elif isinstance(item, Interpolation):
|
|
55
|
+
parts.append(str(item.value))
|
|
56
|
+
raw = "".join(parts)
|
|
57
|
+
markdown = f"${raw}$"
|
|
58
|
+
try:
|
|
59
|
+
import marimo as mo
|
|
60
|
+
return mo.md(markdown)
|
|
61
|
+
except ImportError:
|
|
62
|
+
return markdown
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def block(template: Template) -> Any:
|
|
66
|
+
"""Render a t-string as a display-mode LaTeX block.
|
|
67
|
+
|
|
68
|
+
All interpolations with .latex() are joined; the whole result
|
|
69
|
+
is wrapped in $$...$$.
|
|
70
|
+
|
|
71
|
+
gm.block(t"{expr}") → $$e_{12} + e_{13}$$
|
|
72
|
+
"""
|
|
73
|
+
parts: list[str] = []
|
|
74
|
+
for item in template:
|
|
75
|
+
if isinstance(item, str):
|
|
76
|
+
parts.append(item)
|
|
77
|
+
else:
|
|
78
|
+
from string.templatelib import Interpolation
|
|
79
|
+
if isinstance(item, Interpolation) and _has_latex(item.value):
|
|
80
|
+
parts.append(_get_latex(item.value))
|
|
81
|
+
elif isinstance(item, Interpolation):
|
|
82
|
+
parts.append(str(item.value))
|
|
83
|
+
raw = "".join(parts)
|
|
84
|
+
markdown = f"$${raw}$$"
|
|
85
|
+
try:
|
|
86
|
+
import marimo as mo
|
|
87
|
+
return mo.md(markdown)
|
|
88
|
+
except ImportError:
|
|
89
|
+
return markdown
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class _LatexWrapper:
|
|
93
|
+
"""Wraps a value to force inline LaTeX rendering."""
|
|
94
|
+
def __init__(self, obj: Any):
|
|
95
|
+
self._obj = obj
|
|
96
|
+
|
|
97
|
+
def latex(self) -> str:
|
|
98
|
+
if _has_latex(self._obj):
|
|
99
|
+
return _get_latex(self._obj)
|
|
100
|
+
return str(self._obj)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class _BlockLatexWrapper:
|
|
104
|
+
"""Wraps a value to force block LaTeX rendering."""
|
|
105
|
+
def __init__(self, obj: Any):
|
|
106
|
+
self._obj = obj
|
|
107
|
+
|
|
108
|
+
def latex(self) -> str:
|
|
109
|
+
if _has_latex(self._obj):
|
|
110
|
+
return _get_latex(self._obj)
|
|
111
|
+
return str(self._obj)
|
|
112
|
+
|
|
113
|
+
# Sentinel so renderer picks block mode
|
|
114
|
+
_galaga_block = True
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class _TextWrapper:
|
|
118
|
+
"""Wraps a value to force plain text rendering (no LaTeX)."""
|
|
119
|
+
def __init__(self, obj: Any):
|
|
120
|
+
self._obj = obj
|
|
121
|
+
|
|
122
|
+
def __str__(self) -> str:
|
|
123
|
+
return str(self._obj)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def latex(obj: Any) -> _LatexWrapper:
|
|
127
|
+
"""Force an object to render as inline LaTeX in md().
|
|
128
|
+
|
|
129
|
+
gm.md(t"Result: {gm.latex(value)}")
|
|
130
|
+
"""
|
|
131
|
+
return _LatexWrapper(obj)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def block_latex(obj: Any) -> _BlockLatexWrapper:
|
|
135
|
+
"""Force an object to render as block LaTeX in md().
|
|
136
|
+
|
|
137
|
+
gm.md(t"Equation: {gm.block_latex(expr)}")
|
|
138
|
+
"""
|
|
139
|
+
return _BlockLatexWrapper(obj)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def text(obj: Any) -> _TextWrapper:
|
|
143
|
+
"""Force an object to render as plain text in md().
|
|
144
|
+
|
|
145
|
+
gm.md(t"Debug: {gm.text(mv)}")
|
|
146
|
+
"""
|
|
147
|
+
return _TextWrapper(obj)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _to_marimo(markdown: str) -> Any:
|
|
151
|
+
"""Pass markdown to mo.md() if marimo is available, else return string."""
|
|
152
|
+
try:
|
|
153
|
+
import marimo as mo
|
|
154
|
+
return mo.md(markdown)
|
|
155
|
+
except ImportError:
|
|
156
|
+
return markdown
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class Doc:
|
|
160
|
+
"""Builder for assembling markdown from multiple t-strings.
|
|
161
|
+
|
|
162
|
+
Use when you need loops, conditionals, or programmatic content::
|
|
163
|
+
|
|
164
|
+
with gm.doc() as d:
|
|
165
|
+
d.md(t"# Results")
|
|
166
|
+
for name, e in exprs:
|
|
167
|
+
d.md(t"**{name}:** {e} = {e.eval()}")
|
|
168
|
+
|
|
169
|
+
The context manager returns a marimo Html object on exit.
|
|
170
|
+
You can also call d.render() explicitly.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
def __init__(self):
|
|
174
|
+
self._parts: list[str] = []
|
|
175
|
+
self._result = None
|
|
176
|
+
|
|
177
|
+
def md(self, template: Template) -> None:
|
|
178
|
+
"""Append a rendered t-string as a markdown paragraph."""
|
|
179
|
+
import textwrap
|
|
180
|
+
rendered = render_template(template)
|
|
181
|
+
self._parts.append(textwrap.dedent(rendered).strip())
|
|
182
|
+
|
|
183
|
+
def inline(self, template: Template) -> None:
|
|
184
|
+
"""Append a t-string rendered as inline LaTeX ($...$)."""
|
|
185
|
+
parts: list[str] = []
|
|
186
|
+
for item in template:
|
|
187
|
+
if isinstance(item, str):
|
|
188
|
+
parts.append(item)
|
|
189
|
+
else:
|
|
190
|
+
from string.templatelib import Interpolation
|
|
191
|
+
if isinstance(item, Interpolation) and _has_latex(item.value):
|
|
192
|
+
parts.append(_get_latex(item.value))
|
|
193
|
+
elif isinstance(item, Interpolation):
|
|
194
|
+
parts.append(str(item.value))
|
|
195
|
+
self._parts.append(f"${''.join(parts)}$")
|
|
196
|
+
|
|
197
|
+
def block(self, template: Template) -> None:
|
|
198
|
+
"""Append a t-string rendered as block LaTeX ($$...$$)."""
|
|
199
|
+
parts: list[str] = []
|
|
200
|
+
for item in template:
|
|
201
|
+
if isinstance(item, str):
|
|
202
|
+
parts.append(item)
|
|
203
|
+
else:
|
|
204
|
+
from string.templatelib import Interpolation
|
|
205
|
+
if isinstance(item, Interpolation) and _has_latex(item.value):
|
|
206
|
+
parts.append(_get_latex(item.value))
|
|
207
|
+
elif isinstance(item, Interpolation):
|
|
208
|
+
parts.append(str(item.value))
|
|
209
|
+
self._parts.append(f"$${''.join(parts)}$$")
|
|
210
|
+
|
|
211
|
+
def text(self, s: str) -> None:
|
|
212
|
+
"""Append raw markdown text as a new paragraph (double newline separated)."""
|
|
213
|
+
self._parts.append(s)
|
|
214
|
+
|
|
215
|
+
def line(self, s: str) -> None:
|
|
216
|
+
"""Append a raw line that continues the previous block (single newline).
|
|
217
|
+
|
|
218
|
+
Use for table rows, list items, or any content that must be on
|
|
219
|
+
consecutive lines without a paragraph break.
|
|
220
|
+
"""
|
|
221
|
+
if self._parts:
|
|
222
|
+
self._parts[-1] += "\n" + s
|
|
223
|
+
else:
|
|
224
|
+
self._parts.append(s)
|
|
225
|
+
|
|
226
|
+
def render(self) -> Any:
|
|
227
|
+
"""Join all parts and return as marimo Html or string."""
|
|
228
|
+
markdown = "\n\n".join(self._parts)
|
|
229
|
+
self._result = _to_marimo(markdown)
|
|
230
|
+
return self._result
|
|
231
|
+
|
|
232
|
+
def __enter__(self):
|
|
233
|
+
return self
|
|
234
|
+
|
|
235
|
+
def __exit__(self, *exc):
|
|
236
|
+
if self._result is None:
|
|
237
|
+
self.render()
|
|
238
|
+
return False
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def doc() -> Doc:
|
|
242
|
+
"""Create a markdown builder for loop/conditional content.
|
|
243
|
+
|
|
244
|
+
Usage::
|
|
245
|
+
|
|
246
|
+
with gm.doc() as d:
|
|
247
|
+
d.md(t"# Title")
|
|
248
|
+
for name, expr in items:
|
|
249
|
+
d.md(t"**{name}:** {expr} = {expr.eval()}")
|
|
250
|
+
|
|
251
|
+
Returns a marimo Html object when the context manager exits.
|
|
252
|
+
"""
|
|
253
|
+
return Doc()
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Rendering pipeline for t-string templates.
|
|
2
|
+
|
|
3
|
+
Walks a Template's interpolations, classifies each value, and assembles
|
|
4
|
+
markdown suitable for marimo's mo.md().
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from string.templatelib import Template, Interpolation
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RenderKind(Enum):
|
|
16
|
+
TEXT = "text"
|
|
17
|
+
INLINE_LATEX = "inline_latex"
|
|
18
|
+
BLOCK_LATEX = "block_latex"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class Rendered:
|
|
23
|
+
kind: RenderKind
|
|
24
|
+
value: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# Format specs that override automatic detection
|
|
28
|
+
_SPEC_MAP = {
|
|
29
|
+
"latex": RenderKind.INLINE_LATEX,
|
|
30
|
+
"inline": RenderKind.INLINE_LATEX,
|
|
31
|
+
"block": RenderKind.BLOCK_LATEX,
|
|
32
|
+
"text": RenderKind.TEXT,
|
|
33
|
+
"unicode": RenderKind.TEXT,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _has_latex(obj: Any) -> bool:
|
|
38
|
+
"""Check if an object can produce LaTeX output."""
|
|
39
|
+
return callable(getattr(obj, "latex", None)) or callable(
|
|
40
|
+
getattr(obj, "_repr_latex_", None)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _strip_latex_delimiters(s: str) -> str:
|
|
45
|
+
"""Strip $...$ or $$...$$ wrapping from a LaTeX string."""
|
|
46
|
+
if s.startswith("$$") and s.endswith("$$"):
|
|
47
|
+
return s[2:-2].strip()
|
|
48
|
+
if s.startswith("$") and s.endswith("$"):
|
|
49
|
+
return s[1:-1]
|
|
50
|
+
return s
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _get_latex(obj: Any) -> str:
|
|
54
|
+
"""Extract raw LaTeX string from an object.
|
|
55
|
+
|
|
56
|
+
Prefers .latex() (returns raw LaTeX) over _repr_latex_() (returns
|
|
57
|
+
$-wrapped LaTeX per the Jupyter/IPython protocol).
|
|
58
|
+
"""
|
|
59
|
+
if callable(getattr(obj, "latex", None)):
|
|
60
|
+
return obj.latex()
|
|
61
|
+
return _strip_latex_delimiters(obj._repr_latex_())
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def render_value(value: Any, conversion: str | None, format_spec: str) -> Rendered:
|
|
65
|
+
"""Render a single interpolated value.
|
|
66
|
+
|
|
67
|
+
Priority:
|
|
68
|
+
1. Explicit format spec (:latex, :block, :text, :unicode)
|
|
69
|
+
2. Conversion flag (!r, !s, !a) → always text
|
|
70
|
+
3. Object has .latex() or ._repr_latex_() → inline LaTeX
|
|
71
|
+
4. Fallback → text via str()
|
|
72
|
+
"""
|
|
73
|
+
# Conversion flags force text mode
|
|
74
|
+
if conversion == "r":
|
|
75
|
+
return Rendered(RenderKind.TEXT, repr(value))
|
|
76
|
+
if conversion == "a":
|
|
77
|
+
return Rendered(RenderKind.TEXT, ascii(value))
|
|
78
|
+
if conversion == "s":
|
|
79
|
+
return Rendered(RenderKind.TEXT, str(value))
|
|
80
|
+
|
|
81
|
+
# Explicit format spec
|
|
82
|
+
if format_spec in _SPEC_MAP:
|
|
83
|
+
kind = _SPEC_MAP[format_spec]
|
|
84
|
+
if kind in (RenderKind.INLINE_LATEX, RenderKind.BLOCK_LATEX) and _has_latex(value):
|
|
85
|
+
return Rendered(kind, _get_latex(value))
|
|
86
|
+
return Rendered(RenderKind.TEXT, str(value))
|
|
87
|
+
|
|
88
|
+
# Numeric format specs (e.g. :.3f) → latex with formatted coefficients if possible
|
|
89
|
+
if format_spec:
|
|
90
|
+
if _has_latex(value):
|
|
91
|
+
try:
|
|
92
|
+
return Rendered(RenderKind.INLINE_LATEX, value.latex(coeff_format=format_spec))
|
|
93
|
+
except TypeError:
|
|
94
|
+
pass
|
|
95
|
+
return Rendered(RenderKind.TEXT, format(value, format_spec))
|
|
96
|
+
|
|
97
|
+
# Block latex wrapper
|
|
98
|
+
if getattr(value, "_galaga_block", False) and _has_latex(value):
|
|
99
|
+
return Rendered(RenderKind.BLOCK_LATEX, _get_latex(value))
|
|
100
|
+
|
|
101
|
+
# Auto-detect: objects with .latex() render as inline LaTeX
|
|
102
|
+
if _has_latex(value):
|
|
103
|
+
return Rendered(RenderKind.INLINE_LATEX, _get_latex(value))
|
|
104
|
+
|
|
105
|
+
# Fallback
|
|
106
|
+
return Rendered(RenderKind.TEXT, str(value))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _escape_md(s: str) -> str:
|
|
110
|
+
"""Minimal markdown escaping for interpolated text values."""
|
|
111
|
+
# Only escape characters that would break markdown structure
|
|
112
|
+
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _assemble(rendered: Rendered) -> str:
|
|
116
|
+
"""Convert a Rendered value to its markdown string."""
|
|
117
|
+
if rendered.kind == RenderKind.INLINE_LATEX:
|
|
118
|
+
return f"${rendered.value}$"
|
|
119
|
+
if rendered.kind == RenderKind.BLOCK_LATEX:
|
|
120
|
+
return f"\n\n$${rendered.value}$$\n\n"
|
|
121
|
+
return _escape_md(rendered.value)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def render_template(template: Template) -> str:
|
|
125
|
+
"""Walk a t-string Template and produce a markdown string.
|
|
126
|
+
|
|
127
|
+
Literal parts pass through unchanged. Interpolations are classified
|
|
128
|
+
and rendered according to their type and format spec.
|
|
129
|
+
"""
|
|
130
|
+
parts: list[str] = []
|
|
131
|
+
for item in template:
|
|
132
|
+
if isinstance(item, str):
|
|
133
|
+
parts.append(item)
|
|
134
|
+
elif isinstance(item, Interpolation):
|
|
135
|
+
rendered = render_value(item.value, item.conversion, item.format_spec)
|
|
136
|
+
parts.append(_assemble(rendered))
|
|
137
|
+
return "".join(parts)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "galaga-marimo"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Marimo notebook helpers for geometric algebra — t-string powered LaTeX rendering"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.14"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
authors = [{ name = "Edouard Poor" }]
|
|
9
|
+
keywords = ["geometric-algebra", "marimo", "notebook", "latex", "t-string"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"Intended Audience :: Science/Research",
|
|
13
|
+
"Intended Audience :: Education",
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.14",
|
|
17
|
+
"Topic :: Scientific/Engineering :: Mathematics",
|
|
18
|
+
"Topic :: Scientific/Engineering :: Physics",
|
|
19
|
+
"Framework :: Jupyter",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"galaga>=0.1.0",
|
|
23
|
+
"marimo>=0.21.1",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/edouardp/galaga"
|
|
28
|
+
Repository = "https://github.com/edouardp/galaga"
|
|
29
|
+
Issues = "https://github.com/edouardp/galaga/issues"
|
|
30
|
+
|
|
31
|
+
[build-system]
|
|
32
|
+
requires = ["hatchling"]
|
|
33
|
+
build-backend = "hatchling.build"
|
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
"""Tests for galaga_marimo renderer and API.
|
|
2
|
+
|
|
3
|
+
These tests mock string.templatelib types so they can run on Python 3.13+.
|
|
4
|
+
The rendering logic is tested via render_value() and render_template()
|
|
5
|
+
with synthetic Template/Interpolation objects.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
import types
|
|
10
|
+
import pytest
|
|
11
|
+
from unittest.mock import MagicMock
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# Mock string.templatelib for Python < 3.14
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class FakeInterpolation:
|
|
21
|
+
value: Any
|
|
22
|
+
expression: str = ""
|
|
23
|
+
conversion: str | None = None
|
|
24
|
+
format_spec: str = ""
|
|
25
|
+
|
|
26
|
+
class FakeTemplate:
|
|
27
|
+
"""Mimics string.templatelib.Template iteration."""
|
|
28
|
+
def __init__(self, *items):
|
|
29
|
+
self._items = items
|
|
30
|
+
|
|
31
|
+
def __iter__(self):
|
|
32
|
+
return iter(self._items)
|
|
33
|
+
|
|
34
|
+
# Patch string.templatelib before importing galaga_marimo
|
|
35
|
+
_mock_templatelib = types.ModuleType("string.templatelib")
|
|
36
|
+
_mock_templatelib.Template = FakeTemplate
|
|
37
|
+
_mock_templatelib.Interpolation = FakeInterpolation
|
|
38
|
+
sys.modules["string.templatelib"] = _mock_templatelib
|
|
39
|
+
|
|
40
|
+
from galaga_marimo.renderer import (
|
|
41
|
+
render_value,
|
|
42
|
+
render_template,
|
|
43
|
+
RenderKind,
|
|
44
|
+
Rendered,
|
|
45
|
+
_escape_md,
|
|
46
|
+
_assemble,
|
|
47
|
+
_has_latex,
|
|
48
|
+
_get_latex,
|
|
49
|
+
_strip_latex_delimiters,
|
|
50
|
+
)
|
|
51
|
+
from galaga_marimo.api import md, inline, block, latex, block_latex, text, doc, Doc
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# Helpers — fake GA objects
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
class FakeMultivector:
|
|
59
|
+
"""Mimics ga.Multivector with .latex() and str()."""
|
|
60
|
+
def __init__(self, latex_str: str, unicode_str: str):
|
|
61
|
+
self._latex = latex_str
|
|
62
|
+
self._unicode = unicode_str
|
|
63
|
+
|
|
64
|
+
def latex(self) -> str:
|
|
65
|
+
return self._latex
|
|
66
|
+
|
|
67
|
+
def __str__(self) -> str:
|
|
68
|
+
return self._unicode
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class FakeExpr:
|
|
72
|
+
"""Mimics ga.symbolic.Expr with .latex() and str()."""
|
|
73
|
+
def __init__(self, latex_str: str, unicode_str: str):
|
|
74
|
+
self._latex_str = latex_str
|
|
75
|
+
self._unicode = unicode_str
|
|
76
|
+
|
|
77
|
+
def latex(self) -> str:
|
|
78
|
+
return self._latex_str
|
|
79
|
+
|
|
80
|
+
def __str__(self) -> str:
|
|
81
|
+
return self._unicode
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class PlainObject:
|
|
85
|
+
"""Object with no .latex() method."""
|
|
86
|
+
def __init__(self, s: str):
|
|
87
|
+
self._s = s
|
|
88
|
+
|
|
89
|
+
def __str__(self) -> str:
|
|
90
|
+
return self._s
|
|
91
|
+
|
|
92
|
+
def __repr__(self) -> str:
|
|
93
|
+
return f"PlainObject({self._s!r})"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ReprLatexOnly:
|
|
97
|
+
"""Object with _repr_latex_() but no .latex() — e.g. SymPy, Pandas."""
|
|
98
|
+
def __init__(self, latex_str: str, text_str: str):
|
|
99
|
+
self._latex = latex_str
|
|
100
|
+
self._text = text_str
|
|
101
|
+
|
|
102
|
+
def _repr_latex_(self) -> str:
|
|
103
|
+
return self._latex
|
|
104
|
+
|
|
105
|
+
def __str__(self) -> str:
|
|
106
|
+
return self._text
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
# _repr_latex_ / Jupyter protocol support tests
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
class TestReprLatexProtocol:
|
|
114
|
+
"""Test _repr_latex_() fallback for third-party objects."""
|
|
115
|
+
|
|
116
|
+
def test_strip_inline_delimiters(self):
|
|
117
|
+
assert _strip_latex_delimiters("$x^2$") == "x^2"
|
|
118
|
+
|
|
119
|
+
def test_strip_block_delimiters(self):
|
|
120
|
+
assert _strip_latex_delimiters("$$x^2$$") == "x^2"
|
|
121
|
+
|
|
122
|
+
def test_strip_block_delimiters_with_whitespace(self):
|
|
123
|
+
assert _strip_latex_delimiters("$$\nx^2\n$$") == "x^2"
|
|
124
|
+
|
|
125
|
+
def test_no_delimiters_unchanged(self):
|
|
126
|
+
assert _strip_latex_delimiters("x^2") == "x^2"
|
|
127
|
+
|
|
128
|
+
def test_has_latex_with_latex_method(self):
|
|
129
|
+
obj = FakeMultivector("e_{1}", "e₁")
|
|
130
|
+
assert _has_latex(obj) is True
|
|
131
|
+
|
|
132
|
+
def test_has_latex_with_repr_latex(self):
|
|
133
|
+
obj = ReprLatexOnly("$x^2$", "x²")
|
|
134
|
+
assert _has_latex(obj) is True
|
|
135
|
+
|
|
136
|
+
def test_has_latex_with_neither(self):
|
|
137
|
+
obj = PlainObject("hello")
|
|
138
|
+
assert _has_latex(obj) is False
|
|
139
|
+
|
|
140
|
+
def test_get_latex_prefers_latex_method(self):
|
|
141
|
+
"""When both .latex() and _repr_latex_() exist, .latex() wins."""
|
|
142
|
+
class Both:
|
|
143
|
+
def latex(self): return "from_latex"
|
|
144
|
+
def _repr_latex_(self): return "$from_repr$"
|
|
145
|
+
assert _get_latex(Both()) == "from_latex"
|
|
146
|
+
|
|
147
|
+
def test_get_latex_falls_back_to_repr_latex(self):
|
|
148
|
+
obj = ReprLatexOnly("$\\alpha + \\beta$", "α + β")
|
|
149
|
+
assert _get_latex(obj) == "\\alpha + \\beta"
|
|
150
|
+
|
|
151
|
+
def test_get_latex_strips_block_delimiters(self):
|
|
152
|
+
obj = ReprLatexOnly("$$\\frac{a}{b}$$", "a/b")
|
|
153
|
+
assert _get_latex(obj) == "\\frac{a}{b}"
|
|
154
|
+
|
|
155
|
+
def test_repr_latex_object_auto_detected(self):
|
|
156
|
+
obj = ReprLatexOnly("$x^2 + y^2$", "x² + y²")
|
|
157
|
+
r = render_value(obj, None, "")
|
|
158
|
+
assert r.kind == RenderKind.INLINE_LATEX
|
|
159
|
+
assert r.value == "x^2 + y^2"
|
|
160
|
+
|
|
161
|
+
def test_repr_latex_object_in_template(self):
|
|
162
|
+
obj = ReprLatexOnly("$\\sqrt{2}$", "√2")
|
|
163
|
+
t = FakeTemplate("Value: ", FakeInterpolation(obj))
|
|
164
|
+
result = render_template(t)
|
|
165
|
+
assert result == "Value: $\\sqrt{2}$"
|
|
166
|
+
|
|
167
|
+
def test_repr_latex_with_block_spec(self):
|
|
168
|
+
obj = ReprLatexOnly("$x$", "x")
|
|
169
|
+
r = render_value(obj, None, "block")
|
|
170
|
+
assert r.kind == RenderKind.BLOCK_LATEX
|
|
171
|
+
assert r.value == "x"
|
|
172
|
+
|
|
173
|
+
def test_repr_latex_with_text_spec(self):
|
|
174
|
+
obj = ReprLatexOnly("$x$", "x")
|
|
175
|
+
r = render_value(obj, None, "text")
|
|
176
|
+
assert r.kind == RenderKind.TEXT
|
|
177
|
+
assert r.value == "x"
|
|
178
|
+
|
|
179
|
+
def test_repr_latex_with_str_conversion(self):
|
|
180
|
+
obj = ReprLatexOnly("$x$", "x_text")
|
|
181
|
+
r = render_value(obj, "s", "")
|
|
182
|
+
assert r.kind == RenderKind.TEXT
|
|
183
|
+
assert r.value == "x_text"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ---------------------------------------------------------------------------
|
|
187
|
+
# render_value tests
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
class TestRenderValue:
|
|
191
|
+
"""Test the core value classification logic."""
|
|
192
|
+
|
|
193
|
+
def test_latex_object_auto_detected(self):
|
|
194
|
+
mv = FakeMultivector("e_{1} + e_{2}", "e₁ + e₂")
|
|
195
|
+
r = render_value(mv, None, "")
|
|
196
|
+
assert r.kind == RenderKind.INLINE_LATEX
|
|
197
|
+
assert r.value == "e_{1} + e_{2}"
|
|
198
|
+
|
|
199
|
+
def test_plain_object_becomes_text(self):
|
|
200
|
+
obj = PlainObject("hello")
|
|
201
|
+
r = render_value(obj, None, "")
|
|
202
|
+
assert r.kind == RenderKind.TEXT
|
|
203
|
+
assert r.value == "hello"
|
|
204
|
+
|
|
205
|
+
def test_string_becomes_text(self):
|
|
206
|
+
r = render_value("world", None, "")
|
|
207
|
+
assert r.kind == RenderKind.TEXT
|
|
208
|
+
assert r.value == "world"
|
|
209
|
+
|
|
210
|
+
def test_int_becomes_text(self):
|
|
211
|
+
r = render_value(42, None, "")
|
|
212
|
+
assert r.kind == RenderKind.TEXT
|
|
213
|
+
assert r.value == "42"
|
|
214
|
+
|
|
215
|
+
def test_float_becomes_text(self):
|
|
216
|
+
r = render_value(3.14, None, "")
|
|
217
|
+
assert r.kind == RenderKind.TEXT
|
|
218
|
+
assert r.value == "3.14"
|
|
219
|
+
|
|
220
|
+
# --- Conversion flags ---
|
|
221
|
+
|
|
222
|
+
def test_repr_conversion(self):
|
|
223
|
+
mv = FakeMultivector("e_{1}", "e₁")
|
|
224
|
+
r = render_value(mv, "r", "")
|
|
225
|
+
assert r.kind == RenderKind.TEXT
|
|
226
|
+
# repr of FakeMultivector
|
|
227
|
+
assert "FakeMultivector" in r.value or "e" in r.value
|
|
228
|
+
|
|
229
|
+
def test_str_conversion(self):
|
|
230
|
+
mv = FakeMultivector("e_{1}", "e₁")
|
|
231
|
+
r = render_value(mv, "s", "")
|
|
232
|
+
assert r.kind == RenderKind.TEXT
|
|
233
|
+
assert r.value == "e₁"
|
|
234
|
+
|
|
235
|
+
def test_ascii_conversion(self):
|
|
236
|
+
mv = FakeMultivector("e_{1}", "e₁")
|
|
237
|
+
r = render_value(mv, "a", "")
|
|
238
|
+
assert r.kind == RenderKind.TEXT
|
|
239
|
+
# ascii() escapes non-ASCII
|
|
240
|
+
assert "\\u" in r.value or "e" in r.value
|
|
241
|
+
|
|
242
|
+
# --- Format specs ---
|
|
243
|
+
|
|
244
|
+
def test_latex_format_spec(self):
|
|
245
|
+
mv = FakeMultivector("e_{12}", "e₁₂")
|
|
246
|
+
r = render_value(mv, None, "latex")
|
|
247
|
+
assert r.kind == RenderKind.INLINE_LATEX
|
|
248
|
+
assert r.value == "e_{12}"
|
|
249
|
+
|
|
250
|
+
def test_inline_format_spec(self):
|
|
251
|
+
mv = FakeMultivector("e_{12}", "e₁₂")
|
|
252
|
+
r = render_value(mv, None, "inline")
|
|
253
|
+
assert r.kind == RenderKind.INLINE_LATEX
|
|
254
|
+
assert r.value == "e_{12}"
|
|
255
|
+
|
|
256
|
+
def test_block_format_spec(self):
|
|
257
|
+
mv = FakeMultivector("e_{12}", "e₁₂")
|
|
258
|
+
r = render_value(mv, None, "block")
|
|
259
|
+
assert r.kind == RenderKind.BLOCK_LATEX
|
|
260
|
+
assert r.value == "e_{12}"
|
|
261
|
+
|
|
262
|
+
def test_text_format_spec(self):
|
|
263
|
+
mv = FakeMultivector("e_{12}", "e₁₂")
|
|
264
|
+
r = render_value(mv, None, "text")
|
|
265
|
+
assert r.kind == RenderKind.TEXT
|
|
266
|
+
assert r.value == "e₁₂"
|
|
267
|
+
|
|
268
|
+
def test_unicode_format_spec(self):
|
|
269
|
+
mv = FakeMultivector("e_{12}", "e₁₂")
|
|
270
|
+
r = render_value(mv, None, "unicode")
|
|
271
|
+
assert r.kind == RenderKind.TEXT
|
|
272
|
+
assert r.value == "e₁₂"
|
|
273
|
+
|
|
274
|
+
def test_numeric_format_spec(self):
|
|
275
|
+
r = render_value(3.14159, None, ".2f")
|
|
276
|
+
assert r.kind == RenderKind.TEXT
|
|
277
|
+
assert r.value == "3.14"
|
|
278
|
+
|
|
279
|
+
def test_latex_spec_on_plain_object_falls_back(self):
|
|
280
|
+
obj = PlainObject("hello")
|
|
281
|
+
r = render_value(obj, None, "latex")
|
|
282
|
+
assert r.kind == RenderKind.TEXT
|
|
283
|
+
assert r.value == "hello"
|
|
284
|
+
|
|
285
|
+
def test_block_spec_on_plain_object_falls_back(self):
|
|
286
|
+
obj = PlainObject("hello")
|
|
287
|
+
r = render_value(obj, None, "block")
|
|
288
|
+
assert r.kind == RenderKind.TEXT
|
|
289
|
+
assert r.value == "hello"
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# ---------------------------------------------------------------------------
|
|
293
|
+
# _assemble tests
|
|
294
|
+
# ---------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
class TestAssemble:
|
|
297
|
+
def test_inline_latex(self):
|
|
298
|
+
r = Rendered(RenderKind.INLINE_LATEX, "e_{1}")
|
|
299
|
+
assert _assemble(r) == "$e_{1}$"
|
|
300
|
+
|
|
301
|
+
def test_block_latex(self):
|
|
302
|
+
r = Rendered(RenderKind.BLOCK_LATEX, "e_{1} + e_{2}")
|
|
303
|
+
assert _assemble(r) == "\n\n$$e_{1} + e_{2}$$\n\n"
|
|
304
|
+
|
|
305
|
+
def test_text_escaped(self):
|
|
306
|
+
r = Rendered(RenderKind.TEXT, "<script>alert('xss')</script>")
|
|
307
|
+
result = _assemble(r)
|
|
308
|
+
assert "<script>" not in result
|
|
309
|
+
assert "<script>" in result
|
|
310
|
+
|
|
311
|
+
def test_text_ampersand_escaped(self):
|
|
312
|
+
r = Rendered(RenderKind.TEXT, "a & b")
|
|
313
|
+
assert _assemble(r) == "a & b"
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# ---------------------------------------------------------------------------
|
|
317
|
+
# _escape_md tests
|
|
318
|
+
# ---------------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
class TestEscapeMd:
|
|
321
|
+
def test_angle_brackets(self):
|
|
322
|
+
assert _escape_md("<div>") == "<div>"
|
|
323
|
+
|
|
324
|
+
def test_ampersand(self):
|
|
325
|
+
assert _escape_md("a & b") == "a & b"
|
|
326
|
+
|
|
327
|
+
def test_plain_text_unchanged(self):
|
|
328
|
+
assert _escape_md("hello world") == "hello world"
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# ---------------------------------------------------------------------------
|
|
332
|
+
# render_template tests
|
|
333
|
+
# ---------------------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
class TestRenderTemplate:
|
|
336
|
+
def test_literal_only(self):
|
|
337
|
+
t = FakeTemplate("# Hello world")
|
|
338
|
+
assert render_template(t) == "# Hello world"
|
|
339
|
+
|
|
340
|
+
def test_single_latex_interpolation(self):
|
|
341
|
+
mv = FakeMultivector("e_{1}", "e₁")
|
|
342
|
+
t = FakeTemplate("Vector: ", FakeInterpolation(mv))
|
|
343
|
+
result = render_template(t)
|
|
344
|
+
assert result == "Vector: $e_{1}$"
|
|
345
|
+
|
|
346
|
+
def test_mixed_text_and_latex(self):
|
|
347
|
+
mv = FakeMultivector("e_{12}", "e₁₂")
|
|
348
|
+
t = FakeTemplate("The bivector ", FakeInterpolation(mv), " is cool")
|
|
349
|
+
result = render_template(t)
|
|
350
|
+
assert result == "The bivector $e_{12}$ is cool"
|
|
351
|
+
|
|
352
|
+
def test_plain_value_interpolation(self):
|
|
353
|
+
t = FakeTemplate("Count: ", FakeInterpolation(42))
|
|
354
|
+
result = render_template(t)
|
|
355
|
+
assert result == "Count: 42"
|
|
356
|
+
|
|
357
|
+
def test_block_format_spec(self):
|
|
358
|
+
mv = FakeMultivector("R v \\tilde{R}", "RvR̃")
|
|
359
|
+
t = FakeTemplate("Equation:\n", FakeInterpolation(mv, format_spec="block"))
|
|
360
|
+
result = render_template(t)
|
|
361
|
+
assert "$$" in result
|
|
362
|
+
assert "R v \\tilde{R}" in result
|
|
363
|
+
|
|
364
|
+
def test_multiple_interpolations(self):
|
|
365
|
+
v = FakeMultivector("e_{1}", "e₁")
|
|
366
|
+
w = FakeMultivector("e_{2}", "e₂")
|
|
367
|
+
t = FakeTemplate("", FakeInterpolation(v), " and ", FakeInterpolation(w))
|
|
368
|
+
result = render_template(t)
|
|
369
|
+
assert result == "$e_{1}$ and $e_{2}$"
|
|
370
|
+
|
|
371
|
+
def test_repr_conversion_in_template(self):
|
|
372
|
+
mv = FakeMultivector("e_{1}", "e₁")
|
|
373
|
+
t = FakeTemplate("Debug: ", FakeInterpolation(mv, conversion="r"))
|
|
374
|
+
result = render_template(t)
|
|
375
|
+
assert "$" not in result # should not be LaTeX
|
|
376
|
+
|
|
377
|
+
def test_numeric_format_spec_in_template(self):
|
|
378
|
+
t = FakeTemplate("Pi ≈ ", FakeInterpolation(3.14159, format_spec=".3f"))
|
|
379
|
+
result = render_template(t)
|
|
380
|
+
assert result == "Pi ≈ 3.142"
|
|
381
|
+
|
|
382
|
+
def test_symbolic_expr(self):
|
|
383
|
+
expr = FakeExpr("R v \\tilde{R}", "RvR̃")
|
|
384
|
+
t = FakeTemplate("Result: ", FakeInterpolation(expr))
|
|
385
|
+
result = render_template(t)
|
|
386
|
+
assert result == "Result: $R v \\tilde{R}$"
|
|
387
|
+
|
|
388
|
+
def test_empty_template(self):
|
|
389
|
+
t = FakeTemplate()
|
|
390
|
+
assert render_template(t) == ""
|
|
391
|
+
|
|
392
|
+
def test_html_escaping_in_text_values(self):
|
|
393
|
+
t = FakeTemplate("User: ", FakeInterpolation("<b>admin</b>"))
|
|
394
|
+
result = render_template(t)
|
|
395
|
+
assert "<b>" not in result
|
|
396
|
+
assert "<b>" in result
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# ---------------------------------------------------------------------------
|
|
400
|
+
# Wrapper tests
|
|
401
|
+
# ---------------------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
class TestWrappers:
|
|
404
|
+
def test_latex_wrapper_has_latex(self):
|
|
405
|
+
mv = FakeMultivector("e_{1}", "e₁")
|
|
406
|
+
wrapped = latex(mv)
|
|
407
|
+
assert wrapped.latex() == "e_{1}"
|
|
408
|
+
|
|
409
|
+
def test_latex_wrapper_on_plain_object(self):
|
|
410
|
+
obj = PlainObject("hello")
|
|
411
|
+
wrapped = latex(obj)
|
|
412
|
+
assert wrapped.latex() == "hello"
|
|
413
|
+
|
|
414
|
+
def test_block_latex_wrapper(self):
|
|
415
|
+
mv = FakeMultivector("e_{1}", "e₁")
|
|
416
|
+
wrapped = block_latex(mv)
|
|
417
|
+
assert wrapped.latex() == "e_{1}"
|
|
418
|
+
assert wrapped._galaga_block is True
|
|
419
|
+
|
|
420
|
+
def test_block_latex_wrapper_renders_as_block(self):
|
|
421
|
+
mv = FakeMultivector("e_{1}", "e₁")
|
|
422
|
+
wrapped = block_latex(mv)
|
|
423
|
+
r = render_value(wrapped, None, "")
|
|
424
|
+
assert r.kind == RenderKind.BLOCK_LATEX
|
|
425
|
+
assert r.value == "e_{1}"
|
|
426
|
+
|
|
427
|
+
def test_text_wrapper_no_latex(self):
|
|
428
|
+
mv = FakeMultivector("e_{1}", "e₁")
|
|
429
|
+
wrapped = text(mv)
|
|
430
|
+
assert not hasattr(wrapped, "latex")
|
|
431
|
+
r = render_value(wrapped, None, "")
|
|
432
|
+
assert r.kind == RenderKind.TEXT
|
|
433
|
+
assert r.value == "e₁"
|
|
434
|
+
|
|
435
|
+
def test_text_wrapper_in_template(self):
|
|
436
|
+
mv = FakeMultivector("e_{1}", "e₁")
|
|
437
|
+
wrapped = text(mv)
|
|
438
|
+
t = FakeTemplate("Debug: ", FakeInterpolation(wrapped))
|
|
439
|
+
result = render_template(t)
|
|
440
|
+
assert "$" not in result
|
|
441
|
+
assert "e₁" in result
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
# ---------------------------------------------------------------------------
|
|
445
|
+
# md/inline/block API tests (without marimo)
|
|
446
|
+
# ---------------------------------------------------------------------------
|
|
447
|
+
|
|
448
|
+
class TestApiWithoutMarimo:
|
|
449
|
+
"""Test that API functions produce correct markdown before passing to marimo."""
|
|
450
|
+
|
|
451
|
+
def test_md_produces_correct_markdown(self):
|
|
452
|
+
"""md() passes correct markdown to mo.md(); test via render_template."""
|
|
453
|
+
mv = FakeMultivector("e_{1}", "e₁")
|
|
454
|
+
t = FakeTemplate("Vector: ", FakeInterpolation(mv))
|
|
455
|
+
# render_template is what md() uses internally
|
|
456
|
+
result = render_template(t)
|
|
457
|
+
assert result == "Vector: $e_{1}$"
|
|
458
|
+
|
|
459
|
+
def test_inline_produces_correct_markdown(self):
|
|
460
|
+
mv = FakeMultivector("e_{1}", "e₁")
|
|
461
|
+
t = FakeTemplate("v = ", FakeInterpolation(mv))
|
|
462
|
+
# inline() wraps everything in $...$
|
|
463
|
+
result = inline(t)
|
|
464
|
+
# If marimo is installed, result is a marimo object; that's fine
|
|
465
|
+
# The logic is tested via the string path
|
|
466
|
+
assert result is not None
|
|
467
|
+
|
|
468
|
+
def test_block_produces_correct_markdown(self):
|
|
469
|
+
mv = FakeMultivector("e_{12}", "e₁₂")
|
|
470
|
+
t = FakeTemplate(FakeInterpolation(mv))
|
|
471
|
+
result = block(t)
|
|
472
|
+
assert result is not None
|
|
473
|
+
|
|
474
|
+
def test_md_calls_render_template(self):
|
|
475
|
+
"""Verify md() uses render_template correctly."""
|
|
476
|
+
mv = FakeMultivector("e_{1} + e_{2}", "e₁ + e₂")
|
|
477
|
+
t = FakeTemplate("Equation:\n", FakeInterpolation(mv, format_spec="block"))
|
|
478
|
+
markdown = render_template(t)
|
|
479
|
+
assert "$$" in markdown
|
|
480
|
+
assert "e_{1} + e_{2}" in markdown
|
|
481
|
+
|
|
482
|
+
def test_md_mixed_content_markdown(self):
|
|
483
|
+
mv = FakeMultivector("e_{1}", "e₁")
|
|
484
|
+
t = FakeTemplate(
|
|
485
|
+
"# Heading\n\nThe vector ",
|
|
486
|
+
FakeInterpolation(mv),
|
|
487
|
+
" has magnitude ",
|
|
488
|
+
FakeInterpolation(1.0, format_spec=".1f"),
|
|
489
|
+
)
|
|
490
|
+
markdown = render_template(t)
|
|
491
|
+
assert "# Heading" in markdown
|
|
492
|
+
assert "$e_{1}$" in markdown
|
|
493
|
+
assert "1.0" in markdown
|
|
494
|
+
|
|
495
|
+
def test_md_returns_something(self):
|
|
496
|
+
"""md() returns either a string or a marimo object."""
|
|
497
|
+
mv = FakeMultivector("e_{1}", "e₁")
|
|
498
|
+
t = FakeTemplate("Vector: ", FakeInterpolation(mv))
|
|
499
|
+
result = md(t)
|
|
500
|
+
assert result is not None
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
# ---------------------------------------------------------------------------
|
|
504
|
+
# Integration with real GA objects (if available)
|
|
505
|
+
# ---------------------------------------------------------------------------
|
|
506
|
+
|
|
507
|
+
# ---------------------------------------------------------------------------
|
|
508
|
+
# Doc builder tests
|
|
509
|
+
# ---------------------------------------------------------------------------
|
|
510
|
+
|
|
511
|
+
class TestDoc:
|
|
512
|
+
def test_single_md(self):
|
|
513
|
+
mv = FakeMultivector("e_{1}", "e₁")
|
|
514
|
+
d = Doc()
|
|
515
|
+
d.md(FakeTemplate("Vector: ", FakeInterpolation(mv)))
|
|
516
|
+
result = d.render()
|
|
517
|
+
assert result is not None
|
|
518
|
+
|
|
519
|
+
def test_multiple_md(self):
|
|
520
|
+
mv1 = FakeMultivector("e_{1}", "e₁")
|
|
521
|
+
mv2 = FakeMultivector("e_{2}", "e₂")
|
|
522
|
+
d = Doc()
|
|
523
|
+
d.md(FakeTemplate("First: ", FakeInterpolation(mv1)))
|
|
524
|
+
d.md(FakeTemplate("Second: ", FakeInterpolation(mv2)))
|
|
525
|
+
assert len(d._parts) == 2
|
|
526
|
+
assert "$e_{1}$" in d._parts[0]
|
|
527
|
+
assert "$e_{2}$" in d._parts[1]
|
|
528
|
+
|
|
529
|
+
def test_render_joins_with_double_newline(self):
|
|
530
|
+
d = Doc()
|
|
531
|
+
d.md(FakeTemplate("# Title"))
|
|
532
|
+
d.md(FakeTemplate("Paragraph"))
|
|
533
|
+
markdown = "\n\n".join(d._parts)
|
|
534
|
+
assert "# Title\n\nParagraph" == markdown
|
|
535
|
+
|
|
536
|
+
def test_context_manager_auto_renders(self):
|
|
537
|
+
mv = FakeMultivector("e_{1}", "e₁")
|
|
538
|
+
with doc() as d:
|
|
539
|
+
d.md(FakeTemplate("Result: ", FakeInterpolation(mv)))
|
|
540
|
+
assert d._result is not None
|
|
541
|
+
|
|
542
|
+
def test_text_appends_raw(self):
|
|
543
|
+
d = Doc()
|
|
544
|
+
d.text("**bold text**")
|
|
545
|
+
assert d._parts == ["**bold text**"]
|
|
546
|
+
|
|
547
|
+
def test_loop_pattern(self):
|
|
548
|
+
"""The main use case: building content in a loop."""
|
|
549
|
+
items = [
|
|
550
|
+
("Alpha", FakeMultivector("\\alpha", "α")),
|
|
551
|
+
("Beta", FakeMultivector("\\beta", "β")),
|
|
552
|
+
]
|
|
553
|
+
d = Doc()
|
|
554
|
+
d.md(FakeTemplate("# Results"))
|
|
555
|
+
for name, mv in items:
|
|
556
|
+
d.md(FakeTemplate(f"**{name}:** ", FakeInterpolation(mv)))
|
|
557
|
+
assert len(d._parts) == 3
|
|
558
|
+
assert "# Results" in d._parts[0]
|
|
559
|
+
assert "$\\alpha$" in d._parts[1]
|
|
560
|
+
assert "$\\beta$" in d._parts[2]
|
|
561
|
+
|
|
562
|
+
def test_explicit_render_before_exit(self):
|
|
563
|
+
d = Doc()
|
|
564
|
+
d.md(FakeTemplate("hello"))
|
|
565
|
+
result = d.render()
|
|
566
|
+
assert result is not None
|
|
567
|
+
d.__exit__(None, None, None)
|
|
568
|
+
assert d._result is result
|
|
569
|
+
|
|
570
|
+
def test_inline_in_builder(self):
|
|
571
|
+
mv = FakeMultivector("e_{1}", "e₁")
|
|
572
|
+
d = Doc()
|
|
573
|
+
d.inline(FakeTemplate("v = ", FakeInterpolation(mv)))
|
|
574
|
+
assert "$v = e_{1}$" in d._parts[0]
|
|
575
|
+
|
|
576
|
+
def test_block_in_builder(self):
|
|
577
|
+
mv = FakeMultivector("e_{12}", "e₁₂")
|
|
578
|
+
d = Doc()
|
|
579
|
+
d.block(FakeTemplate(FakeInterpolation(mv)))
|
|
580
|
+
assert "$$" in d._parts[0]
|
|
581
|
+
assert "e_{12}" in d._parts[0]
|
|
582
|
+
|
|
583
|
+
def test_line_appends_to_previous(self):
|
|
584
|
+
d = Doc()
|
|
585
|
+
d.line("| A | B |")
|
|
586
|
+
d.line("|---|---|")
|
|
587
|
+
d.line("| 1 | 2 |")
|
|
588
|
+
assert len(d._parts) == 1
|
|
589
|
+
assert "| A | B |\n|---|---|\n| 1 | 2 |" == d._parts[0]
|
|
590
|
+
|
|
591
|
+
def test_line_on_empty_creates_part(self):
|
|
592
|
+
d = Doc()
|
|
593
|
+
d.line("first line")
|
|
594
|
+
assert d._parts == ["first line"]
|
|
595
|
+
|
|
596
|
+
def test_md_then_line_table(self):
|
|
597
|
+
d = Doc()
|
|
598
|
+
d.md(FakeTemplate("# Title"))
|
|
599
|
+
d.text("| H1 | H2 |")
|
|
600
|
+
d.line("|---|---|")
|
|
601
|
+
d.line("| a | b |")
|
|
602
|
+
assert len(d._parts) == 2
|
|
603
|
+
assert d._parts[0] == "# Title"
|
|
604
|
+
assert d._parts[1] == "| H1 | H2 |\n|---|---|\n| a | b |"
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
# ---------------------------------------------------------------------------
|
|
608
|
+
# Integration with real GA objects (if available)
|
|
609
|
+
# ---------------------------------------------------------------------------
|
|
610
|
+
|
|
611
|
+
class TestGAIntegration:
|
|
612
|
+
"""Test with actual galaga Multivector objects."""
|
|
613
|
+
|
|
614
|
+
@pytest.fixture
|
|
615
|
+
def vga(self):
|
|
616
|
+
try:
|
|
617
|
+
from ga import Algebra
|
|
618
|
+
return Algebra((1, 1, 1))
|
|
619
|
+
except ImportError:
|
|
620
|
+
pytest.skip("galaga not installed")
|
|
621
|
+
|
|
622
|
+
def test_multivector_auto_latex(self, vga):
|
|
623
|
+
e1, e2, e3 = vga.basis_vectors()
|
|
624
|
+
v = 3 * e1 + 2 * e2
|
|
625
|
+
r = render_value(v, None, "")
|
|
626
|
+
assert r.kind == RenderKind.INLINE_LATEX
|
|
627
|
+
assert "e" in r.value # contains basis element names
|
|
628
|
+
|
|
629
|
+
def test_multivector_in_template(self, vga):
|
|
630
|
+
e1, e2, e3 = vga.basis_vectors()
|
|
631
|
+
v = e1 + e2
|
|
632
|
+
t = FakeTemplate("v = ", FakeInterpolation(v))
|
|
633
|
+
result = render_template(t)
|
|
634
|
+
assert result.startswith("v = $")
|
|
635
|
+
assert result.endswith("$")
|
|
636
|
+
|
|
637
|
+
def test_multivector_text_override(self, vga):
|
|
638
|
+
e1, e2, e3 = vga.basis_vectors()
|
|
639
|
+
v = e1
|
|
640
|
+
r = render_value(v, None, "text")
|
|
641
|
+
assert r.kind == RenderKind.TEXT
|
|
642
|
+
assert "$" not in r.value
|
|
643
|
+
|
|
644
|
+
def test_multivector_block_spec(self, vga):
|
|
645
|
+
e1, e2, e3 = vga.basis_vectors()
|
|
646
|
+
v = e1 + e2 + e3
|
|
647
|
+
r = render_value(v, None, "block")
|
|
648
|
+
assert r.kind == RenderKind.BLOCK_LATEX
|
|
649
|
+
|
|
650
|
+
def test_multivector_str_conversion(self, vga):
|
|
651
|
+
e1, e2, e3 = vga.basis_vectors()
|
|
652
|
+
v = e1
|
|
653
|
+
r = render_value(v, "s", "")
|
|
654
|
+
assert r.kind == RenderKind.TEXT
|
|
655
|
+
|
|
656
|
+
def test_scalar_multivector(self, vga):
|
|
657
|
+
s = vga.scalar(5.0)
|
|
658
|
+
r = render_value(s, None, "")
|
|
659
|
+
assert r.kind == RenderKind.INLINE_LATEX
|
|
660
|
+
assert "5" in r.value
|
|
661
|
+
|
|
662
|
+
def test_zero_multivector(self, vga):
|
|
663
|
+
z = vga.scalar(0.0)
|
|
664
|
+
r = render_value(z, None, "")
|
|
665
|
+
assert r.kind == RenderKind.INLINE_LATEX
|
|
666
|
+
assert "0" in r.value
|
|
667
|
+
|
|
668
|
+
def test_full_template_with_ga(self, vga):
|
|
669
|
+
e1, e2, e3 = vga.basis_vectors()
|
|
670
|
+
v = 2 * e1 - e3
|
|
671
|
+
t = FakeTemplate(
|
|
672
|
+
"# Result\n\nThe vector ",
|
|
673
|
+
FakeInterpolation(v),
|
|
674
|
+
" has components in e1 and e3.",
|
|
675
|
+
)
|
|
676
|
+
result = render_template(t)
|
|
677
|
+
assert "# Result" in result
|
|
678
|
+
assert "$" in result
|
|
679
|
+
assert "has components" in result
|