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.
@@ -0,0 +1,14 @@
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
+ .coverage
12
+ .DS_Store
13
+ __marimo__/
14
+ packages/galaga/ga/.*.swp
@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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 "&lt;script&gt;" in result
310
+
311
+ def test_text_ampersand_escaped(self):
312
+ r = Rendered(RenderKind.TEXT, "a & b")
313
+ assert _assemble(r) == "a &amp; b"
314
+
315
+
316
+ # ---------------------------------------------------------------------------
317
+ # _escape_md tests
318
+ # ---------------------------------------------------------------------------
319
+
320
+ class TestEscapeMd:
321
+ def test_angle_brackets(self):
322
+ assert _escape_md("<div>") == "&lt;div&gt;"
323
+
324
+ def test_ampersand(self):
325
+ assert _escape_md("a & b") == "a &amp; 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 "&lt;b&gt;" 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