latex2code 0.2.0__py3-none-any.whl

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.
latex2code/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ from latex2code.core import (
4
+ Backend,
5
+ CodeGenerationError,
6
+ CompiledFormula,
7
+ InvalidLaTeXSyntaxError,
8
+ InvalidPythonIdentifierError,
9
+ LaTeXTranspilerError,
10
+ PiecewiseEvaluationWarning,
11
+ TranspilationInfo,
12
+ UnsupportedLaTeXFeatureError,
13
+ compile_latex,
14
+ inspect_latex,
15
+ latex_to_code,
16
+ transpile_latex,
17
+ )
18
+
19
+ try:
20
+ __version__ = version("latex2code")
21
+ except PackageNotFoundError:
22
+ __version__ = "0+unknown"
23
+ __all__ = [
24
+ "__version__",
25
+ "Backend",
26
+ "transpile_latex",
27
+ "compile_latex",
28
+ "inspect_latex",
29
+ "latex_to_code",
30
+ "CompiledFormula",
31
+ "TranspilationInfo",
32
+ "LaTeXTranspilerError",
33
+ "CodeGenerationError",
34
+ "InvalidLaTeXSyntaxError",
35
+ "UnsupportedLaTeXFeatureError",
36
+ "InvalidPythonIdentifierError",
37
+ "PiecewiseEvaluationWarning",
38
+ ]
latex2code/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
latex2code/api.py ADDED
@@ -0,0 +1,461 @@
1
+ import importlib.util
2
+ import re
3
+ import warnings
4
+ from dataclasses import dataclass
5
+ from typing import Callable, Mapping, Sequence
6
+
7
+ import sympy as sp
8
+
9
+ from .backends import BACKENDS, Backend
10
+ from .errors import (
11
+ CodeGenerationError,
12
+ InvalidLaTeXSyntaxError,
13
+ LaTeXTranspilerError,
14
+ PiecewiseEvaluationWarning,
15
+ _validate_python_identifier,
16
+ )
17
+ from .normalize import _normalize_latex_string
18
+ from .parsing import (
19
+ _LATEX_PARSE_ERRORS,
20
+ _check_for_undefined_commands,
21
+ _ordered_free_symbols,
22
+ _parse_cases_environment,
23
+ _parse_expression,
24
+ _parse_matrix_environment,
25
+ _parse_operatorname_expression,
26
+ )
27
+ from .printers import _expression_tree, _FrameworkPrinter, _PythonPrinter
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class TranspilationInfo:
32
+ """Parsed expression and generated source returned by :func:`inspect_latex`."""
33
+
34
+ input_expression: str
35
+ backend: str
36
+ parsed_expression: str
37
+ expression_tree: dict[str, object]
38
+ variables: tuple[str, ...]
39
+ generated_code: str
40
+ warnings: tuple[str, ...]
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class CompiledFormula:
45
+ """Callable generated formula together with its source and inspection details."""
46
+
47
+ info: TranspilationInfo
48
+ function: Callable[..., object]
49
+
50
+ def __call__(self, *args: object, **kwargs: object) -> object:
51
+ return self.function(*args, **kwargs)
52
+
53
+ @property
54
+ def source(self) -> str:
55
+ return self.info.generated_code
56
+
57
+
58
+ def _transpile_latex(
59
+ latex_str: str,
60
+ *,
61
+ function_name: str,
62
+ type_hints: bool,
63
+ use_numpy: bool | None,
64
+ backend: Backend | None,
65
+ variable_map: Mapping[str, str] | None,
66
+ args: Sequence[str] | None,
67
+ constants: Mapping[str, object] | None,
68
+ warning_stacklevel: int,
69
+ ) -> TranspilationInfo:
70
+ """Build generated source and inspection details from a LaTeX expression."""
71
+ if not latex_str or not latex_str.strip():
72
+ raise LaTeXTranspilerError("LaTeX expression string cannot be empty.")
73
+ _validate_python_identifier(function_name, "Function name")
74
+ if use_numpy is not None:
75
+ warnings.warn(
76
+ "use_numpy is deprecated; use backend='numpy' instead.",
77
+ DeprecationWarning,
78
+ stacklevel=warning_stacklevel,
79
+ )
80
+ if backend is None:
81
+ backend = "numpy" if use_numpy else "python"
82
+ elif not isinstance(backend, str) or backend not in {
83
+ "python",
84
+ "numpy",
85
+ "torch",
86
+ "jax",
87
+ }:
88
+ raise LaTeXTranspilerError(
89
+ f"Unknown backend {backend!r}; choose 'python', 'numpy', 'torch', or 'jax'."
90
+ )
91
+ if use_numpy and backend != "numpy":
92
+ raise LaTeXTranspilerError(
93
+ "use_numpy=True conflicts with an explicit backend; use backend='numpy' instead."
94
+ )
95
+ backend_spec = BACKENDS[backend]
96
+ normalized_latex = _normalize_latex_string(latex_str)
97
+
98
+ # 1. Parse matrix, piecewise, or general expression
99
+ try:
100
+ expr = _parse_matrix_environment(normalized_latex)
101
+ is_matrix = expr is not None
102
+
103
+ if not is_matrix:
104
+ expr = _parse_cases_environment(normalized_latex)
105
+ if expr is not None:
106
+ is_matrix = False
107
+
108
+ if expr is None:
109
+ _check_for_undefined_commands(sp.Symbol("temp"), normalized_latex)
110
+ if r"\operatorname" in normalized_latex:
111
+ expr = _parse_operatorname_expression(normalized_latex)
112
+ else:
113
+ expr = _parse_expression(normalized_latex)
114
+ _check_for_undefined_commands(expr, normalized_latex)
115
+
116
+ # Evaluate derivatives, limits, or integrals symbolically if present
117
+ if (
118
+ expr.has(sp.Derivative)
119
+ or expr.has(sp.Integral)
120
+ or expr.has(sp.Limit)
121
+ or expr.has(sp.Product)
122
+ ):
123
+ expr = expr.doit()
124
+ except _LATEX_PARSE_ERRORS as exc:
125
+ raise InvalidLaTeXSyntaxError(latex_str, original_error=exc) from exc
126
+
127
+ constant_values: Mapping[str, object]
128
+ if constants is None:
129
+ constant_values = {"pi": sp.pi, "e": sp.E}
130
+ else:
131
+ if not isinstance(constants, Mapping):
132
+ raise LaTeXTranspilerError(
133
+ "constants must map symbol names to numeric values."
134
+ )
135
+ if not all(
136
+ isinstance(name, str) and isinstance(value, (int, float, complex, sp.Expr))
137
+ for name, value in constants.items()
138
+ ):
139
+ raise LaTeXTranspilerError(
140
+ "constant names must be strings and values must be numbers or SymPy expressions."
141
+ )
142
+ unused_constants = (
143
+ set(constants) - {str(symbol) for symbol in expr.free_symbols} - {"pi"}
144
+ )
145
+ if unused_constants:
146
+ raise LaTeXTranspilerError(
147
+ "constants contains symbols not present in the expression: "
148
+ f"{', '.join(sorted(unused_constants))}."
149
+ )
150
+ constant_values = constants
151
+ expr = expr.subs(
152
+ {sp.Symbol(name): sp.sympify(value) for name, value in constant_values.items()}
153
+ )
154
+
155
+ # 2. Extract free variables
156
+ variables = _ordered_free_symbols(expr, normalized_latex)
157
+ if args is not None:
158
+ if (
159
+ isinstance(args, (str, bytes))
160
+ or not isinstance(args, Sequence)
161
+ or not all(isinstance(argument, str) for argument in args)
162
+ ):
163
+ raise LaTeXTranspilerError("args must be a sequence of symbol names.")
164
+ if len(set(args)) != len(args) or set(args) != set(variables):
165
+ raise LaTeXTranspilerError(
166
+ "args must contain every expression variable exactly once."
167
+ )
168
+ variables = list(args)
169
+ if variable_map is not None:
170
+ if not isinstance(variable_map, Mapping):
171
+ raise LaTeXTranspilerError(
172
+ "variable_map must map symbol names to Python identifiers."
173
+ )
174
+ if not all(
175
+ isinstance(source, str) and isinstance(target, str)
176
+ for source, target in variable_map.items()
177
+ ):
178
+ raise LaTeXTranspilerError("variable_map keys and values must be strings.")
179
+ unknown_symbols = set(variable_map) - set(variables)
180
+ if unknown_symbols:
181
+ raise LaTeXTranspilerError(
182
+ "variable_map contains symbols not present in the expression: "
183
+ f"{', '.join(sorted(unknown_symbols))}."
184
+ )
185
+ mapped_variables = [
186
+ variable_map.get(variable, variable) for variable in variables
187
+ ]
188
+ for variable in mapped_variables:
189
+ _validate_python_identifier(variable, "Mapped argument name")
190
+ if len(set(mapped_variables)) != len(mapped_variables):
191
+ raise LaTeXTranspilerError(
192
+ "variable_map must not map multiple symbols to the same Python identifier."
193
+ )
194
+ expr = expr.xreplace(
195
+ {
196
+ sp.Symbol(source): sp.Symbol(target)
197
+ for source, target in variable_map.items()
198
+ }
199
+ )
200
+ variables = mapped_variables
201
+ transpilation_warnings = []
202
+ for variable in variables:
203
+ _validate_python_identifier(variable, "Generated argument name")
204
+ if backend in {"numpy", "torch", "jax"} and expr.has(sp.Piecewise):
205
+ warning_message = (
206
+ "Array-backend piecewise expressions use eager elementwise selection; "
207
+ "all branch expressions may be evaluated, including branches not selected "
208
+ "for an element. Keep every branch valid over the full input domain."
209
+ )
210
+ transpilation_warnings.append(warning_message)
211
+ warnings.warn(
212
+ warning_message,
213
+ PiecewiseEvaluationWarning,
214
+ stacklevel=warning_stacklevel,
215
+ )
216
+
217
+ # 3. Build argument signature
218
+ argument_type = backend_spec.argument_type
219
+ return_type = (
220
+ backend_spec.matrix_return_type
221
+ if is_matrix
222
+ else backend_spec.scalar_return_type
223
+ )
224
+ if type_hints:
225
+ args_str = ", ".join([f"{var}: {argument_type}" for var in variables])
226
+ return_hint = f" -> {return_type}"
227
+ else:
228
+ args_str = ", ".join(variables)
229
+ return_hint = ""
230
+
231
+ # 4. Generate Python code representation
232
+ printer: _PythonPrinter | _FrameworkPrinter | None = None
233
+ try:
234
+ if is_matrix and backend in {"torch", "jax"}:
235
+ printer = _FrameworkPrinter(backend)
236
+ matrix_rows = [
237
+ "[" + ", ".join(printer.doprint(value) for value in row) + "]"
238
+ for row in expr.tolist()
239
+ ]
240
+ if backend == "torch" and variables:
241
+ like = variables[0]
242
+ rows_with_tensors = [
243
+ "["
244
+ + ", ".join(
245
+ f"({value} if torch.is_tensor({value}) else "
246
+ f"torch.as_tensor({value}, dtype={like}.dtype, device={like}.device))"
247
+ for value in row
248
+ )
249
+ + "]"
250
+ for row in expr.tolist()
251
+ ]
252
+ python_expr_code = (
253
+ "torch.stack([torch.stack(row) for row in ["
254
+ + ", ".join(rows_with_tensors)
255
+ + "]])"
256
+ )
257
+ elif backend == "torch":
258
+ python_expr_code = f"torch.tensor([{', '.join(matrix_rows)}])"
259
+ else:
260
+ python_expr_code = f"jnp.array([{', '.join(matrix_rows)}])"
261
+ elif is_matrix:
262
+ matrix_list = expr.tolist()
263
+ module = backend_spec.module_alias or "np"
264
+ python_expr_code = f"{module}.array({matrix_list})"
265
+ elif backend == "numpy":
266
+ printer = _FrameworkPrinter(backend)
267
+ python_expr_code = printer.doprint(expr)
268
+ elif backend in {"torch", "jax"}:
269
+ printer = _FrameworkPrinter(backend)
270
+ python_expr_code = printer.doprint(expr)
271
+ else:
272
+ printer = _PythonPrinter()
273
+ python_expr_code = printer.doprint(expr)
274
+ printer_modules = set(printer.module_imports) if printer is not None else set()
275
+ if backend in {"torch", "jax"} and "math" in printer_modules:
276
+ raise CodeGenerationError(
277
+ f"The {backend} backend has no differentiable native implementation for "
278
+ f"part of this expression: {expr}"
279
+ )
280
+ except Exception as exc:
281
+ if isinstance(exc, CodeGenerationError):
282
+ raise
283
+ raise CodeGenerationError(
284
+ f"Unable to generate Python code for parsed expression: {expr}"
285
+ ) from exc
286
+
287
+ # 5. Assemble required imports
288
+ imports_list = list(backend_spec.imports)
289
+ if backend == "python":
290
+ if is_matrix:
291
+ imports_list.append("import numpy as np")
292
+ for module in ("builtins", "math", "functools"):
293
+ if module in printer_modules:
294
+ imports_list.append(f"import {module}")
295
+
296
+ imports = "\n".join(imports_list)
297
+ if imports:
298
+ imports += "\n\n"
299
+
300
+ # 6. Construct and return complete source code
301
+ code = f"{imports}def {function_name}({args_str}){return_hint}:\n"
302
+ code += f" return {python_expr_code}\n"
303
+
304
+ return TranspilationInfo(
305
+ input_expression=latex_str,
306
+ backend=backend,
307
+ parsed_expression=str(expr),
308
+ expression_tree=_expression_tree(expr),
309
+ variables=tuple(variables),
310
+ generated_code=code,
311
+ warnings=tuple(transpilation_warnings),
312
+ )
313
+
314
+
315
+ def transpile_latex(
316
+ latex_str: str,
317
+ *,
318
+ function_name: str = "formula",
319
+ type_hints: bool = True,
320
+ use_numpy: bool | None = None,
321
+ backend: Backend | None = None,
322
+ variable_map: Mapping[str, str] | None = None,
323
+ args: Sequence[str] | None = None,
324
+ constants: Mapping[str, object] | None = None,
325
+ _warning_stacklevel: int = 3,
326
+ ) -> str:
327
+ """Convert LaTeX into Python source targeting Python math, NumPy, PyTorch, or JAX."""
328
+ return _transpile_latex(
329
+ latex_str,
330
+ function_name=function_name,
331
+ type_hints=type_hints,
332
+ use_numpy=use_numpy,
333
+ backend=backend,
334
+ variable_map=variable_map,
335
+ args=args,
336
+ constants=constants,
337
+ warning_stacklevel=_warning_stacklevel,
338
+ ).generated_code
339
+
340
+
341
+ def inspect_latex(
342
+ latex_str: str,
343
+ *,
344
+ function_name: str = "formula",
345
+ type_hints: bool = True,
346
+ use_numpy: bool | None = None,
347
+ backend: Backend | None = None,
348
+ variable_map: Mapping[str, str] | None = None,
349
+ args: Sequence[str] | None = None,
350
+ constants: Mapping[str, object] | None = None,
351
+ _warning_stacklevel: int = 3,
352
+ ) -> TranspilationInfo:
353
+ """Return the parsed expression, variables, warnings, and generated source."""
354
+ return _transpile_latex(
355
+ latex_str,
356
+ function_name=function_name,
357
+ type_hints=type_hints,
358
+ use_numpy=use_numpy,
359
+ backend=backend,
360
+ variable_map=variable_map,
361
+ args=args,
362
+ constants=constants,
363
+ warning_stacklevel=_warning_stacklevel,
364
+ )
365
+
366
+
367
+ def compile_latex(
368
+ latex_str: str,
369
+ *,
370
+ function_name: str = "formula",
371
+ type_hints: bool = True,
372
+ use_numpy: bool | None = None,
373
+ backend: Backend | None = None,
374
+ variable_map: Mapping[str, str] | None = None,
375
+ args: Sequence[str] | None = None,
376
+ constants: Mapping[str, object] | None = None,
377
+ ) -> CompiledFormula:
378
+ """Compile LaTeX into a callable and retain its generated source and metadata."""
379
+ selected_backend = backend or ("numpy" if use_numpy else "python")
380
+ if selected_backend == "torch" and importlib.util.find_spec("torch") is None:
381
+ raise LaTeXTranspilerError(
382
+ "The 'torch' backend requires the optional dependency 'torch'. "
383
+ "Install it with `pip install latex2code[torch]`."
384
+ )
385
+ if selected_backend == "jax" and importlib.util.find_spec("jax") is None:
386
+ raise LaTeXTranspilerError(
387
+ "The 'jax' backend requires the optional dependency 'jax'. "
388
+ "Install it with `pip install latex2code[jax]`."
389
+ )
390
+ info = inspect_latex(
391
+ latex_str,
392
+ function_name=function_name,
393
+ type_hints=type_hints,
394
+ use_numpy=use_numpy,
395
+ backend=backend,
396
+ variable_map=variable_map,
397
+ args=args,
398
+ constants=constants,
399
+ _warning_stacklevel=4,
400
+ )
401
+ namespace: dict[str, object] = {}
402
+ exec(info.generated_code, namespace)
403
+ function = namespace[function_name]
404
+ if not callable(function):
405
+ raise CodeGenerationError(
406
+ f"Generated object {function_name!r} is not callable."
407
+ )
408
+ return CompiledFormula(info=info, function=function)
409
+
410
+
411
+ def latex_to_code(
412
+ latex_str: str,
413
+ *,
414
+ custom_symbol_map: dict[str, str] | None = None,
415
+ function_name: str = "formula",
416
+ type_hints: bool = True,
417
+ use_numpy: bool | None = None,
418
+ backend: Backend | None = None,
419
+ variable_map: Mapping[str, str] | None = None,
420
+ args: Sequence[str] | None = None,
421
+ constants: Mapping[str, object] | None = None,
422
+ ) -> str:
423
+ """Convert LaTeX into executable Python code using the main transpile pipeline.
424
+
425
+ This helper preserves the historical `custom_symbol_map` behavior for macro
426
+ overrides while also accepting the same generation options as
427
+ :func:`transpile_latex`. Values are remapped via the standard variable map
428
+ after the LaTeX parser has interpreted the expression.
429
+ """
430
+ merged_variable_map = dict(variable_map) if variable_map is not None else {}
431
+
432
+ if custom_symbol_map is not None:
433
+ warnings.warn(
434
+ "custom_symbol_map is deprecated; use variable_map instead.",
435
+ DeprecationWarning,
436
+ stacklevel=2,
437
+ )
438
+ for macro, python_var in custom_symbol_map.items():
439
+ if not isinstance(macro, str) or not macro.startswith("\\"):
440
+ raise LaTeXTranspilerError(
441
+ "custom_symbol_map keys must be LaTeX command strings like '\\lambda'."
442
+ )
443
+ symbol_name = macro.lstrip("\\")
444
+ if not re.fullmatch(r"[A-Za-z]+", symbol_name):
445
+ raise LaTeXTranspilerError(
446
+ "custom_symbol_map supports plain LaTeX symbol commands such as '\\lambda'; "
447
+ f"received {macro!r}."
448
+ )
449
+ merged_variable_map[symbol_name] = python_var
450
+
451
+ return transpile_latex(
452
+ latex_str,
453
+ function_name=function_name,
454
+ type_hints=type_hints,
455
+ use_numpy=use_numpy,
456
+ backend=backend,
457
+ variable_map=merged_variable_map or None,
458
+ args=args,
459
+ constants=constants,
460
+ _warning_stacklevel=4,
461
+ )
latex2code/backends.py ADDED
@@ -0,0 +1,88 @@
1
+ from dataclasses import dataclass
2
+ from typing import Literal, Mapping
3
+
4
+ Backend = Literal["python", "numpy", "torch", "jax"]
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class BackendSpec:
9
+ argument_type: str
10
+ scalar_return_type: str
11
+ matrix_return_type: str
12
+ module_alias: str | None
13
+ imports: tuple[str, ...]
14
+ function_aliases: Mapping[str, str]
15
+ special_functions: Mapping[str, str]
16
+
17
+
18
+ _FUNCTION_ALIASES = {
19
+ "sin": "sin",
20
+ "cos": "cos",
21
+ "tan": "tan",
22
+ "cot": "cot",
23
+ "sec": "sec",
24
+ "csc": "csc",
25
+ "sinh": "sinh",
26
+ "cosh": "cosh",
27
+ "tanh": "tanh",
28
+ "coth": "coth",
29
+ "sech": "sech",
30
+ "csch": "csch",
31
+ "acos": "arccos",
32
+ "acosh": "arccosh",
33
+ "asin": "arcsin",
34
+ "asinh": "arcsinh",
35
+ "atan": "arctan",
36
+ "atanh": "arctanh",
37
+ "exp": "exp",
38
+ "log": "log",
39
+ "ceiling": "ceil",
40
+ "floor": "floor",
41
+ "sqrt": "sqrt",
42
+ "ln": "log",
43
+ "Sqrt": "sqrt",
44
+ }
45
+
46
+
47
+ BACKENDS: dict[Backend, BackendSpec] = {
48
+ "python": BackendSpec("float", "float", "np.ndarray", None, (), {}, {}),
49
+ "numpy": BackendSpec(
50
+ "float | np.ndarray",
51
+ "float | np.ndarray",
52
+ "np.ndarray",
53
+ "np",
54
+ ("import numpy as np", "import math"),
55
+ _FUNCTION_ALIASES,
56
+ {
57
+ "erf": "np.vectorize(math.erf)",
58
+ "erfc": "np.vectorize(math.erfc)",
59
+ "gamma": "np.vectorize(math.gamma)",
60
+ },
61
+ ),
62
+ "torch": BackendSpec(
63
+ "torch.Tensor",
64
+ "torch.Tensor",
65
+ "torch.Tensor",
66
+ "torch",
67
+ ("import torch",),
68
+ _FUNCTION_ALIASES,
69
+ {
70
+ "erf": "torch.erf",
71
+ "erfc": "torch.special.erfc",
72
+ "gamma": "torch.special.gamma",
73
+ },
74
+ ),
75
+ "jax": BackendSpec(
76
+ "jax.Array",
77
+ "jax.Array",
78
+ "jax.Array",
79
+ "jnp",
80
+ ("import jax", "import jax.numpy as jnp", "import jax.scipy.special"),
81
+ _FUNCTION_ALIASES,
82
+ {
83
+ "erf": "jax.scipy.special.erf",
84
+ "erfc": "jax.scipy.special.erfc",
85
+ "gamma": "jax.scipy.special.gamma",
86
+ },
87
+ ),
88
+ }