phml.embedded

Embedded has all the logic for processing python elements, attributes, and text blocks.

  1"""
  2Embedded has all the logic for processing python elements, attributes, and text blocks.
  3"""
  4from __future__ import annotations
  5
  6import ast
  7from functools import cached_property
  8from html import escape
  9import re
 10import types
 11from pathlib import Path
 12from shutil import get_terminal_size
 13from traceback import FrameSummary, extract_tb
 14from typing import Any, Iterator, TypedDict
 15
 16from phml.embedded.built_in import built_in_funcs, built_in_types
 17from phml.helpers import normalize_indent
 18from phml.nodes import Element, Literal
 19
 20ESCAPE_OPTIONS = {
 21    "quote": False,
 22}
 23
 24# Global cached imports
 25__IMPORTS__ = {}
 26__FROM_IMPORTS__ = {}
 27
 28
 29# PERF: Only allow assignments, methods, imports, and classes?
 30class EmbeddedTryCatch:
 31    """Context manager around embedded python execution. Will parse the traceback
 32    and the content being executed to create a detailed error message. The final
 33    error message is raised in a custom EmbeddedPythonException.
 34    """
 35
 36    def __init__(
 37        self,
 38        path: str | Path | None = None,
 39        content: str | None = None,
 40        pos: tuple[int, int] | None = None,
 41    ) -> None:
 42        self._path = str(path or "<python>")
 43        self._content = content or ""
 44        self._pos = pos or (0, 0)
 45
 46    def __enter__(self):
 47        pass
 48
 49    def __exit__(self, _, exc_val, exc_tb):
 50        if exc_val is not None and not isinstance(exc_val, SystemExit):
 51            raise EmbeddedPythonException(
 52                self._path,
 53                self._content,
 54                self._pos,
 55                exc_val,
 56                exc_tb,
 57            ) from exc_val
 58
 59
 60class EmbeddedPythonException(Exception):
 61    def __init__(self, path, content, pos, exc_val, exc_tb) -> None:
 62        self.max_width, _ = get_terminal_size((20, 0))
 63        self.msg = exc_val.msg if hasattr(exc_val, "msg") else str(exc_val)
 64        if isinstance(exc_val, SyntaxError):
 65            self.l_slice = (exc_val.lineno or 0, exc_val.end_lineno or 0)
 66            self.c_slice = (exc_val.offset or 0, exc_val.end_offset or 0)
 67        else:
 68            fs: FrameSummary = extract_tb(exc_tb)[-1]
 69            self.l_slice = (fs.lineno or 0, fs.end_lineno or 0)
 70            self.c_slice = (fs.colno or 0, fs.end_colno or 0)
 71
 72        self._content = content
 73        self._path = path
 74        self._pos = pos
 75
 76    def format_line(self, line, c_width, leading: str = " "):
 77        return f"{leading.ljust(c_width, ' ')}{line}"
 78
 79    def generate_exception_lines(self, lines: list[str], width: int):
 80        max_width = self.max_width - width - 3
 81        result = []
 82        for i, line in enumerate(lines):
 83            if len(line) > max_width:
 84                parts = [
 85                    line[j : j + max_width] for j in range(0, len(line), max_width)
 86                ]
 87                result.append(self.format_line(parts[0], width, str(i + 1)))
 88                for part in parts[1:]:
 89                    result.append(self.format_line(part, width))
 90            else:
 91                result.append(self.format_line(line, width, str(i + 1)))
 92        return result
 93
 94    def __str__(self) -> str:
 95        message = ""
 96        if self._path != "":
 97            pos = (
 98                self._pos[0] + (self.l_slice[0] or 0),
 99                self.c_slice[0] or self._pos[1],
100            )
101            if pos[0] > self._content.count("\n"):
102                message = f"{self._path} Failed to execute phml embedded python"
103            else:
104                message = f"[{pos[0]+1}:{pos[1]}] {self._path} Failed to execute phml embedded python"
105        if self._content != "":
106            lines = self._content.split("\n")
107            target_lines = lines[self.l_slice[0] - 1 : self.l_slice[1]]
108            if len(target_lines) > 0:
109                if self.l_slice[0] == self.l_slice[1]:
110                    target_lines[0] = (
111                        target_lines[0][: self.c_slice[0]]
112                        + "\x1b[31m"
113                        + target_lines[0][self.c_slice[0] : self.c_slice[1]]
114                        + "\x1b[0m"
115                        + target_lines[0][self.c_slice[1] :]
116                    )
117                else:
118                    target_lines[0] = (
119                        target_lines[0][: self.c_slice[0] + 1]
120                        + "\x1b[31m"
121                        + target_lines[0][self.c_slice[0] + 1 :]
122                        + "\x1b[0m"
123                    )
124                    for i, line in enumerate(target_lines[1:-1]):
125                        target_lines[i + 1] = "\x1b[31m" + line + "\x1b[0m"
126                    target_lines[-1] = (
127                        "\x1b[31m"
128                        + target_lines[-1][: self.c_slice[-1] + 1]
129                        + "\x1b[0m"
130                        + target_lines[-1][self.c_slice[-1] + 1 :]
131                    )
132
133                lines = [
134                    *lines[: self.l_slice[0] - 1],
135                    *target_lines,
136                    *lines[self.l_slice[1] :],
137                ]
138
139            w_fmt = len(f"{len(lines)}")
140            content = "\n".join(
141                self.generate_exception_lines(lines, w_fmt),
142            )
143            line_width = self.max_width - w_fmt - 2
144
145            exception = f"{self.msg}"
146            if len(target_lines) > 0:
147                exception += f" at <{self.l_slice[0]}:{self.c_slice[0]}-{self.l_slice[1]}:{self.c_slice[1]}>"
148            ls = [
149                exception[i : i + line_width]
150                for i in range(0, len(exception), line_width)
151            ]
152            exception_line = self.format_line(ls[0], w_fmt, "#")
153            for l in ls[1:]:
154                exception_line += "\n" + self.format_line(l, w_fmt)
155
156            message += (
157                f"\n{'─'.ljust(w_fmt, '─')}┬─{'─'*(line_width)}\n"
158                + exception_line
159                + "\n"
160                + f"{'═'.ljust(w_fmt, '═')}╪═{'═'*(line_width)}\n"
161                + f"{content}"
162            )
163
164        return message
165
166
167def parse_import_values(_import: str) -> list[str | tuple[str, str]]:
168    values = []
169    for value in re.finditer(r"(?:([^,\s]+) as (.+)|([^,\s]+))(?=\s*,)?", _import):
170        if value.group(1) is not None:
171            values.append((value.group(1), value.group(2)))
172        elif value.groups(3) is not None:
173            values.append(value.group(3))
174    return values
175
176
177class ImportStruct(TypedDict):
178    key: str
179    values: str | list[str]
180
181
182class Module:
183    """Object used to access the gobal imports. Readonly data."""
184
185    def __init__(self, module: str, *, imports: list[str] | None = None) -> None:
186        self.objects = imports or []
187        if imports is not None and len(imports) > 0:
188            if module not in __FROM_IMPORTS__:
189                raise ValueError(f"Unkown module {module!r}")
190            try:
191                imports = {
192                    _import: __FROM_IMPORTS__[module][_import] for _import in imports
193                }
194            except KeyError as kerr:
195                back_frame = kerr.__traceback__.tb_frame.f_back
196                back_tb = types.TracebackType(
197                    tb_next=None,
198                    tb_frame=back_frame,
199                    tb_lasti=back_frame.f_lasti,
200                    tb_lineno=back_frame.f_lineno,
201                )
202                FrameSummary("", 2, "")
203                raise ValueError(
204                    f"{', '.join(kerr.args)!r} {'arg' if len(kerr.args) > 1 else 'is'} not found in cached imported module {module!r}",
205                ).with_traceback(back_tb)
206
207            globals().update(imports)
208            locals().update(imports)
209            self.module = module
210        else:
211            if module not in __IMPORTS__:
212                raise ValueError(f"Unkown module {module!r}")
213
214            imports = {module: __IMPORTS__[module]}
215            locals().update(imports)
216            globals().update(imports)
217            self.module = module
218
219    def collect(self) -> Any:
220        """Collect the imports and return the single import or a tuple of multiple imports."""
221        if len(self.objects) > 0:
222            if len(self.objects) == 1:
223                return __FROM_IMPORTS__[self.module][self.objects[0]]
224            return tuple(
225                [__FROM_IMPORTS__[self.module][object] for object in self.objects]
226            )
227        return __IMPORTS__[self.module]
228
229
230class EmbeddedImport:
231    """Data representation of an import."""
232
233    module: str
234    """Package where the import(s) are from."""
235
236    objects: list[str]
237    """The imported objects."""
238
239    def __init__(
240        self, module: str, values: str | list[str] | None = None, *, push: bool = False
241    ) -> None:
242        self.module = module
243
244        if isinstance(values, list):
245            self.objects = values
246        else:
247            self.objects = parse_import_values(values or "")
248
249        if push:
250            self.data
251
252    def _parse_from_import(self):
253        if self.module in __FROM_IMPORTS__:
254            values = list(
255                filter(
256                    lambda v: (v if isinstance(v, str) else v[0])
257                    not in __FROM_IMPORTS__[self.module],
258                    self.objects,
259                )
260            )
261        else:
262            values = self.objects
263
264        if len(values) > 0:
265            local_env = {}
266            exec_val = compile(str(self), "_embedded_import_", "exec")
267            exec(exec_val, {}, local_env)
268
269            if self.module not in __FROM_IMPORTS__:
270                __FROM_IMPORTS__[self.module] = {}
271            __FROM_IMPORTS__[self.module].update(local_env)
272
273        keys = [key if isinstance(key, str) else key[1] for key in self.objects]
274        return {key: __FROM_IMPORTS__[self.module][key] for key in keys}
275
276    def _parse_import(self):
277        if self.module not in __IMPORTS__:
278            local_env = {}
279            exec_val = compile(str(self), "_embedded_import_", "exec")
280            exec(exec_val, {}, local_env)
281            __IMPORTS__.update(local_env)
282
283        return {self.module: __IMPORTS__[self.module]}
284
285    def __iter__(self) -> Iterator[tuple[str, Any]]:
286        if len(self.objects) > 0:
287            if self.module not in __FROM_IMPORTS__:
288                raise KeyError(f"{self.module} is not a known exposed module")
289            yield from __FROM_IMPORTS__[self.module].items()
290        else:
291            if self.module not in __IMPORTS__:
292                raise KeyError(f"{self.module} is not a known exposed module")
293            yield __IMPORTS__[self.module]
294
295    @cached_property
296    def data(self) -> dict[str, Any]:
297        """The actual imports stored by a name to value mapping."""
298        if len(self.objects) > 0:
299            return self._parse_from_import()
300        return self._parse_import()
301
302    def __getitem__(self, key: str) -> Any:
303        self.data[key]
304
305    def __repr__(self) -> str:
306        if len(self.objects) > 0:
307            return f"FROM({self.module}).IMPORT({', '.join(self.objects)})"
308        return f"IMPORT({self.module})"
309
310    def __str__(self) -> str:
311        if len(self.objects) > 0:
312            return f"from {self.module} import {', '.join(obj if isinstance(obj, str) else f'{obj[0]} as {obj[1]}' for obj in self.objects)}"
313        return f"import {self.module}"
314
315
316class Embedded:
317    """Logic for parsing and storing locals and imports of dynamic python code."""
318
319    context: dict[str, Any]
320    """Variables and locals found in the python code block."""
321
322    imports: list[EmbeddedImport]
323    """Imports needed for the python in this scope. Imports are stored in the module globally
324    to reduce duplicate imports.
325    """
326
327    def __init__(self, content: str | Element, path: str | None = None) -> None:
328        self._path = path or "<python>"
329        self._pos = (0, 0)
330        if isinstance(content, Element):
331            if len(content) > 1 or (
332                len(content) == 1 and not Literal.is_text(content[0])
333            ):
334                # TODO: Custom error
335                raise ValueError(
336                    "Expected python elements to contain one text node or nothing",
337                )
338            if content.position is not None:
339                start = content.position.start
340                self._pos = (start.line, start.column)
341            content = content[0].content
342        content = normalize_indent(content)
343        self.imports = []
344        self.context = {}
345        if len(content) > 0:
346            with EmbeddedTryCatch(path, content, self._pos):
347                self.parse_data(content)
348
349    def __add__(self, _o) -> Embedded:
350        self.imports.extend(_o.imports)
351        self.context.update(_o.context)
352        return self
353
354    def __contains__(self, key: str) -> bool:
355        return key in self.context
356
357    def __getitem__(self, key: str) -> Any:
358        if key in self.context:
359            return self.context[key]
360        elif key in self.imports:
361            return __IMPORTS__[key]
362
363        raise KeyError(f"Key is not in Embedded context or imports: {key}")
364
365    def split_contexts(self, content: str) -> tuple[list[str], list[EmbeddedImport]]:
366        re_context = re.compile(r"class.+|def.+")
367        re_import = re.compile(
368            r"from (?P<key>.+) import (?P<values>.+)|import (?P<value>.+)",
369        )
370
371        imports = []
372        blocks = []
373        current = []
374
375        lines = content.split("\n")
376        i = 0
377        while i < len(lines):
378            imp_match = re_import.match(lines[i])
379            if imp_match is not None:
380                data = imp_match.groupdict()
381                imports.append(
382                    EmbeddedImport(data["key"] or data["value"], data["values"])
383                )
384            elif re_context.match(lines[i]) is not None:
385                blocks.append("\n".join(current))
386                current = [lines[i]]
387                i += 1
388                while i < len(lines) and lines[i].startswith(" "):
389                    current.append(lines[i])
390                    i += 1
391                blocks.append("\n".join(current))
392                current = []
393            else:
394                current.append(lines[i])
395            if i < len(lines):
396                i += 1
397
398        if len(current) > 0:
399            blocks.append("\n".join(current))
400
401        return blocks, imports
402
403    def parse_data(self, content: str):
404        blocks, self.imports = self.split_contexts(content)
405
406        local_env = {}
407        global_env = {key: value for _import in self.imports for key, value in _import}
408        context = {**global_env}
409
410        for block in blocks:
411            exec_val = compile(block, self._path, "exec")
412            exec(exec_val, global_env, local_env)
413            context.update(local_env)
414            # update global env with found locals so they can be used inside methods and classes
415            global_env.update(local_env)
416
417        self.context = context
418
419
420def _validate_kwargs(code: ast.Module, kwargs: dict[str, Any]):
421    exclude_list = [*built_in_funcs, *built_in_types]
422    for var in (
423        name.id
424        for name in ast.walk(code)
425        if isinstance(
426            name,
427            ast.Name,
428        )  # Get all variables/names used. This can be methods or values
429        and name.id not in exclude_list
430    ):
431        if var not in kwargs:
432            kwargs[var] = None
433
434
435def update_ast_node_pos(dest, source):
436    """Assign lineno, end_lineno, col_offset, and end_col_offset
437    from a source python ast node to a destination python ast node.
438    """
439    dest.lineno = source.lineno
440    dest.end_lineno = source.end_lineno
441    dest.col_offset = source.col_offset
442    dest.end_col_offset = source.end_col_offset
443
444
445RESULT = "_phml_embedded_result_"
446
447
448def exec_embedded(code: str, _path: str | None = None, **context: Any) -> Any:
449    """Execute embedded python and return the extracted value. This is the last
450    assignment in the embedded python. The embedded python must have the last line as a value
451    or an assignment.
452
453    Note:
454        No local or global variables will be retained from the embedded python code.
455
456    Args:
457        code (str): The embedded python code.
458        **context (Any): The additional context to provide to the embedded python.
459
460    Returns:
461        Any: The value of the last assignment or value defined
462    """
463    from phml.utilities import blank
464
465    context = {
466        "blank": blank,
467        **context,
468    }
469
470    # last line must be an assignment or the value to be used
471    with EmbeddedTryCatch(_path, code):
472        code = normalize_indent(code)
473        AST = ast.parse(code)
474        _validate_kwargs(AST, context)
475
476        last = AST.body[-1]
477        returns = [ret for ret in AST.body if isinstance(ret, ast.Return)]
478
479        if len(returns) > 0:
480            last = returns[0]
481            idx = AST.body.index(last)
482
483            n_expr = ast.Name(id=RESULT, ctx=ast.Store())
484            n_assign = ast.Assign(targets=[n_expr], value=last.value)
485
486            update_ast_node_pos(dest=n_expr, source=last)
487            update_ast_node_pos(dest=n_assign, source=last)
488
489            AST.body = [*AST.body[:idx], n_assign]
490        elif isinstance(last, ast.Expr):
491            n_expr = ast.Name(id=RESULT, ctx=ast.Store())
492            n_assign = ast.Assign(targets=[n_expr], value=last.value)
493
494            update_ast_node_pos(dest=n_expr, source=last)
495            update_ast_node_pos(dest=n_assign, source=last)
496
497            AST.body[-1] = n_assign
498        elif isinstance(last, ast.Assign):
499            n_expr = ast.Name(id=RESULT, ctx=ast.Store())
500            update_ast_node_pos(dest=n_expr, source=last)
501            last.targets.append(n_expr)
502
503        ccode = compile(AST, "_phml_embedded_", "exec")
504        local_env = {}
505        exec(ccode, {**context}, local_env)
506
507        if isinstance(local_env[RESULT], str):
508            return escape(local_env[RESULT], **ESCAPE_OPTIONS)
509        return local_env[RESULT]
510
511
512def exec_embedded_blocks(code: str, _path: str = "", **context: dict[str, Any]):
513    """Execute embedded python inside `{{}}` blocks. The resulting values are subsituted
514    in for the found blocks.
515
516    Note:
517        No local or global variables will be retained from the embedded python code.
518
519    Args:
520        code (str): The embedded python code.
521        **context (Any): The additional context to provide to the embedded python.
522
523    Returns:
524        str: The value of the passed in string with the python blocks replaced.
525    """
526
527    result = [""]
528    data = []
529    next_block = re.search(r"\{\{", code)
530    while next_block is not None:
531        start = next_block.start()
532        if start > 0:
533            result[-1] += code[:start]
534        code = code[start + 2 :]
535
536        balance = 2
537        index = 0
538        while balance > 0 and index < len(code):
539            if code[index] == "}":
540                balance -= 1
541            elif code[index] == "{":
542                balance += 1
543            index += 1
544
545        result.append("")
546        data.append(
547            str(
548                exec_embedded(
549                    code[: index - 2].strip(),
550                    _path + f" block #{len(data)+1}",
551                    **context,
552                ),
553            ),
554        )
555        code = code[index:]
556        next_block = re.search(r"(?<!\\)\{\{", code)
557
558    if len(code) > 0:
559        result[-1] += code
560
561    if len(data) != len(result) - 1:
562        raise ValueError(
563            f"Not enough data to replace inline python blocks: expected {len(result) - 1} but there was {len(data)}"
564        )
565
566    def merge(dest: list, source: list) -> list:
567        """Merge source into dest. For every item in source place each item between items of dest.
568        If there is more items in source the spaces between items in dest then the extra items in source
569        are ignored.
570
571        Example:
572            dest = [1, 2, 3]
573            source = ["red", "blue", "green"]
574            merge(dest, source) == [1, "red", 2, "blue", 3]
575
576            or
577
578            dest = [1, 2, 3]
579            source = ["red"]
580            merge(dest, source) == [1, "red", 2, 3]
581        """
582        combination = []
583        for f_item, s_item in zip(dest, source):
584            combination.extend([f_item, s_item])
585
586        idx = len(combination) // 2
587        if idx < len(dest):
588            combination.extend(dest[idx:])
589        return combination
590
591    return "".join(merge(result, data))
class EmbeddedTryCatch:
31class EmbeddedTryCatch:
32    """Context manager around embedded python execution. Will parse the traceback
33    and the content being executed to create a detailed error message. The final
34    error message is raised in a custom EmbeddedPythonException.
35    """
36
37    def __init__(
38        self,
39        path: str | Path | None = None,
40        content: str | None = None,
41        pos: tuple[int, int] | None = None,
42    ) -> None:
43        self._path = str(path or "<python>")
44        self._content = content or ""
45        self._pos = pos or (0, 0)
46
47    def __enter__(self):
48        pass
49
50    def __exit__(self, _, exc_val, exc_tb):
51        if exc_val is not None and not isinstance(exc_val, SystemExit):
52            raise EmbeddedPythonException(
53                self._path,
54                self._content,
55                self._pos,
56                exc_val,
57                exc_tb,
58            ) from exc_val

Context manager around embedded python execution. Will parse the traceback and the content being executed to create a detailed error message. The final error message is raised in a custom EmbeddedPythonException.

EmbeddedTryCatch( path: str | pathlib.Path | None = None, content: str | None = None, pos: tuple[int, int] | None = None)
37    def __init__(
38        self,
39        path: str | Path | None = None,
40        content: str | None = None,
41        pos: tuple[int, int] | None = None,
42    ) -> None:
43        self._path = str(path or "<python>")
44        self._content = content or ""
45        self._pos = pos or (0, 0)
class EmbeddedPythonException(builtins.Exception):
 61class EmbeddedPythonException(Exception):
 62    def __init__(self, path, content, pos, exc_val, exc_tb) -> None:
 63        self.max_width, _ = get_terminal_size((20, 0))
 64        self.msg = exc_val.msg if hasattr(exc_val, "msg") else str(exc_val)
 65        if isinstance(exc_val, SyntaxError):
 66            self.l_slice = (exc_val.lineno or 0, exc_val.end_lineno or 0)
 67            self.c_slice = (exc_val.offset or 0, exc_val.end_offset or 0)
 68        else:
 69            fs: FrameSummary = extract_tb(exc_tb)[-1]
 70            self.l_slice = (fs.lineno or 0, fs.end_lineno or 0)
 71            self.c_slice = (fs.colno or 0, fs.end_colno or 0)
 72
 73        self._content = content
 74        self._path = path
 75        self._pos = pos
 76
 77    def format_line(self, line, c_width, leading: str = " "):
 78        return f"{leading.ljust(c_width, ' ')}{line}"
 79
 80    def generate_exception_lines(self, lines: list[str], width: int):
 81        max_width = self.max_width - width - 3
 82        result = []
 83        for i, line in enumerate(lines):
 84            if len(line) > max_width:
 85                parts = [
 86                    line[j : j + max_width] for j in range(0, len(line), max_width)
 87                ]
 88                result.append(self.format_line(parts[0], width, str(i + 1)))
 89                for part in parts[1:]:
 90                    result.append(self.format_line(part, width))
 91            else:
 92                result.append(self.format_line(line, width, str(i + 1)))
 93        return result
 94
 95    def __str__(self) -> str:
 96        message = ""
 97        if self._path != "":
 98            pos = (
 99                self._pos[0] + (self.l_slice[0] or 0),
100                self.c_slice[0] or self._pos[1],
101            )
102            if pos[0] > self._content.count("\n"):
103                message = f"{self._path} Failed to execute phml embedded python"
104            else:
105                message = f"[{pos[0]+1}:{pos[1]}] {self._path} Failed to execute phml embedded python"
106        if self._content != "":
107            lines = self._content.split("\n")
108            target_lines = lines[self.l_slice[0] - 1 : self.l_slice[1]]
109            if len(target_lines) > 0:
110                if self.l_slice[0] == self.l_slice[1]:
111                    target_lines[0] = (
112                        target_lines[0][: self.c_slice[0]]
113                        + "\x1b[31m"
114                        + target_lines[0][self.c_slice[0] : self.c_slice[1]]
115                        + "\x1b[0m"
116                        + target_lines[0][self.c_slice[1] :]
117                    )
118                else:
119                    target_lines[0] = (
120                        target_lines[0][: self.c_slice[0] + 1]
121                        + "\x1b[31m"
122                        + target_lines[0][self.c_slice[0] + 1 :]
123                        + "\x1b[0m"
124                    )
125                    for i, line in enumerate(target_lines[1:-1]):
126                        target_lines[i + 1] = "\x1b[31m" + line + "\x1b[0m"
127                    target_lines[-1] = (
128                        "\x1b[31m"
129                        + target_lines[-1][: self.c_slice[-1] + 1]
130                        + "\x1b[0m"
131                        + target_lines[-1][self.c_slice[-1] + 1 :]
132                    )
133
134                lines = [
135                    *lines[: self.l_slice[0] - 1],
136                    *target_lines,
137                    *lines[self.l_slice[1] :],
138                ]
139
140            w_fmt = len(f"{len(lines)}")
141            content = "\n".join(
142                self.generate_exception_lines(lines, w_fmt),
143            )
144            line_width = self.max_width - w_fmt - 2
145
146            exception = f"{self.msg}"
147            if len(target_lines) > 0:
148                exception += f" at <{self.l_slice[0]}:{self.c_slice[0]}-{self.l_slice[1]}:{self.c_slice[1]}>"
149            ls = [
150                exception[i : i + line_width]
151                for i in range(0, len(exception), line_width)
152            ]
153            exception_line = self.format_line(ls[0], w_fmt, "#")
154            for l in ls[1:]:
155                exception_line += "\n" + self.format_line(l, w_fmt)
156
157            message += (
158                f"\n{'─'.ljust(w_fmt, '─')}┬─{'─'*(line_width)}\n"
159                + exception_line
160                + "\n"
161                + f"{'═'.ljust(w_fmt, '═')}╪═{'═'*(line_width)}\n"
162                + f"{content}"
163            )
164
165        return message

Common base class for all non-exit exceptions.

EmbeddedPythonException(path, content, pos, exc_val, exc_tb)
62    def __init__(self, path, content, pos, exc_val, exc_tb) -> None:
63        self.max_width, _ = get_terminal_size((20, 0))
64        self.msg = exc_val.msg if hasattr(exc_val, "msg") else str(exc_val)
65        if isinstance(exc_val, SyntaxError):
66            self.l_slice = (exc_val.lineno or 0, exc_val.end_lineno or 0)
67            self.c_slice = (exc_val.offset or 0, exc_val.end_offset or 0)
68        else:
69            fs: FrameSummary = extract_tb(exc_tb)[-1]
70            self.l_slice = (fs.lineno or 0, fs.end_lineno or 0)
71            self.c_slice = (fs.colno or 0, fs.end_colno or 0)
72
73        self._content = content
74        self._path = path
75        self._pos = pos
def format_line(self, line, c_width, leading: str = ' '):
77    def format_line(self, line, c_width, leading: str = " "):
78        return f"{leading.ljust(c_width, ' ')}{line}"
def generate_exception_lines(self, lines: list[str], width: int):
80    def generate_exception_lines(self, lines: list[str], width: int):
81        max_width = self.max_width - width - 3
82        result = []
83        for i, line in enumerate(lines):
84            if len(line) > max_width:
85                parts = [
86                    line[j : j + max_width] for j in range(0, len(line), max_width)
87                ]
88                result.append(self.format_line(parts[0], width, str(i + 1)))
89                for part in parts[1:]:
90                    result.append(self.format_line(part, width))
91            else:
92                result.append(self.format_line(line, width, str(i + 1)))
93        return result
Inherited Members
builtins.BaseException
with_traceback
add_note
def parse_import_values(_import: str) -> list[str | tuple[str, str]]:
168def parse_import_values(_import: str) -> list[str | tuple[str, str]]:
169    values = []
170    for value in re.finditer(r"(?:([^,\s]+) as (.+)|([^,\s]+))(?=\s*,)?", _import):
171        if value.group(1) is not None:
172            values.append((value.group(1), value.group(2)))
173        elif value.groups(3) is not None:
174            values.append(value.group(3))
175    return values
class ImportStruct(typing.TypedDict):
178class ImportStruct(TypedDict):
179    key: str
180    values: str | list[str]
def values(unknown):

D.values() -> an object providing a view on D's values

Inherited Members
builtins.dict
get
setdefault
pop
popitem
keys
items
update
fromkeys
clear
copy
class Module:
183class Module:
184    """Object used to access the gobal imports. Readonly data."""
185
186    def __init__(self, module: str, *, imports: list[str] | None = None) -> None:
187        self.objects = imports or []
188        if imports is not None and len(imports) > 0:
189            if module not in __FROM_IMPORTS__:
190                raise ValueError(f"Unkown module {module!r}")
191            try:
192                imports = {
193                    _import: __FROM_IMPORTS__[module][_import] for _import in imports
194                }
195            except KeyError as kerr:
196                back_frame = kerr.__traceback__.tb_frame.f_back
197                back_tb = types.TracebackType(
198                    tb_next=None,
199                    tb_frame=back_frame,
200                    tb_lasti=back_frame.f_lasti,
201                    tb_lineno=back_frame.f_lineno,
202                )
203                FrameSummary("", 2, "")
204                raise ValueError(
205                    f"{', '.join(kerr.args)!r} {'arg' if len(kerr.args) > 1 else 'is'} not found in cached imported module {module!r}",
206                ).with_traceback(back_tb)
207
208            globals().update(imports)
209            locals().update(imports)
210            self.module = module
211        else:
212            if module not in __IMPORTS__:
213                raise ValueError(f"Unkown module {module!r}")
214
215            imports = {module: __IMPORTS__[module]}
216            locals().update(imports)
217            globals().update(imports)
218            self.module = module
219
220    def collect(self) -> Any:
221        """Collect the imports and return the single import or a tuple of multiple imports."""
222        if len(self.objects) > 0:
223            if len(self.objects) == 1:
224                return __FROM_IMPORTS__[self.module][self.objects[0]]
225            return tuple(
226                [__FROM_IMPORTS__[self.module][object] for object in self.objects]
227            )
228        return __IMPORTS__[self.module]

Object used to access the gobal imports. Readonly data.

Module(module: str, *, imports: list[str] | None = None)
186    def __init__(self, module: str, *, imports: list[str] | None = None) -> None:
187        self.objects = imports or []
188        if imports is not None and len(imports) > 0:
189            if module not in __FROM_IMPORTS__:
190                raise ValueError(f"Unkown module {module!r}")
191            try:
192                imports = {
193                    _import: __FROM_IMPORTS__[module][_import] for _import in imports
194                }
195            except KeyError as kerr:
196                back_frame = kerr.__traceback__.tb_frame.f_back
197                back_tb = types.TracebackType(
198                    tb_next=None,
199                    tb_frame=back_frame,
200                    tb_lasti=back_frame.f_lasti,
201                    tb_lineno=back_frame.f_lineno,
202                )
203                FrameSummary("", 2, "")
204                raise ValueError(
205                    f"{', '.join(kerr.args)!r} {'arg' if len(kerr.args) > 1 else 'is'} not found in cached imported module {module!r}",
206                ).with_traceback(back_tb)
207
208            globals().update(imports)
209            locals().update(imports)
210            self.module = module
211        else:
212            if module not in __IMPORTS__:
213                raise ValueError(f"Unkown module {module!r}")
214
215            imports = {module: __IMPORTS__[module]}
216            locals().update(imports)
217            globals().update(imports)
218            self.module = module
def collect(self) -> Any:
220    def collect(self) -> Any:
221        """Collect the imports and return the single import or a tuple of multiple imports."""
222        if len(self.objects) > 0:
223            if len(self.objects) == 1:
224                return __FROM_IMPORTS__[self.module][self.objects[0]]
225            return tuple(
226                [__FROM_IMPORTS__[self.module][object] for object in self.objects]
227            )
228        return __IMPORTS__[self.module]

Collect the imports and return the single import or a tuple of multiple imports.

class EmbeddedImport:
231class EmbeddedImport:
232    """Data representation of an import."""
233
234    module: str
235    """Package where the import(s) are from."""
236
237    objects: list[str]
238    """The imported objects."""
239
240    def __init__(
241        self, module: str, values: str | list[str] | None = None, *, push: bool = False
242    ) -> None:
243        self.module = module
244
245        if isinstance(values, list):
246            self.objects = values
247        else:
248            self.objects = parse_import_values(values or "")
249
250        if push:
251            self.data
252
253    def _parse_from_import(self):
254        if self.module in __FROM_IMPORTS__:
255            values = list(
256                filter(
257                    lambda v: (v if isinstance(v, str) else v[0])
258                    not in __FROM_IMPORTS__[self.module],
259                    self.objects,
260                )
261            )
262        else:
263            values = self.objects
264
265        if len(values) > 0:
266            local_env = {}
267            exec_val = compile(str(self), "_embedded_import_", "exec")
268            exec(exec_val, {}, local_env)
269
270            if self.module not in __FROM_IMPORTS__:
271                __FROM_IMPORTS__[self.module] = {}
272            __FROM_IMPORTS__[self.module].update(local_env)
273
274        keys = [key if isinstance(key, str) else key[1] for key in self.objects]
275        return {key: __FROM_IMPORTS__[self.module][key] for key in keys}
276
277    def _parse_import(self):
278        if self.module not in __IMPORTS__:
279            local_env = {}
280            exec_val = compile(str(self), "_embedded_import_", "exec")
281            exec(exec_val, {}, local_env)
282            __IMPORTS__.update(local_env)
283
284        return {self.module: __IMPORTS__[self.module]}
285
286    def __iter__(self) -> Iterator[tuple[str, Any]]:
287        if len(self.objects) > 0:
288            if self.module not in __FROM_IMPORTS__:
289                raise KeyError(f"{self.module} is not a known exposed module")
290            yield from __FROM_IMPORTS__[self.module].items()
291        else:
292            if self.module not in __IMPORTS__:
293                raise KeyError(f"{self.module} is not a known exposed module")
294            yield __IMPORTS__[self.module]
295
296    @cached_property
297    def data(self) -> dict[str, Any]:
298        """The actual imports stored by a name to value mapping."""
299        if len(self.objects) > 0:
300            return self._parse_from_import()
301        return self._parse_import()
302
303    def __getitem__(self, key: str) -> Any:
304        self.data[key]
305
306    def __repr__(self) -> str:
307        if len(self.objects) > 0:
308            return f"FROM({self.module}).IMPORT({', '.join(self.objects)})"
309        return f"IMPORT({self.module})"
310
311    def __str__(self) -> str:
312        if len(self.objects) > 0:
313            return f"from {self.module} import {', '.join(obj if isinstance(obj, str) else f'{obj[0]} as {obj[1]}' for obj in self.objects)}"
314        return f"import {self.module}"

Data representation of an import.

EmbeddedImport( module: str, values: str | list[str] | None = None, *, push: bool = False)
240    def __init__(
241        self, module: str, values: str | list[str] | None = None, *, push: bool = False
242    ) -> None:
243        self.module = module
244
245        if isinstance(values, list):
246            self.objects = values
247        else:
248            self.objects = parse_import_values(values or "")
249
250        if push:
251            self.data
module: str

Package where the import(s) are from.

objects: list[str]

The imported objects.

data: dict[str, typing.Any]

The actual imports stored by a name to value mapping.

class Embedded:
317class Embedded:
318    """Logic for parsing and storing locals and imports of dynamic python code."""
319
320    context: dict[str, Any]
321    """Variables and locals found in the python code block."""
322
323    imports: list[EmbeddedImport]
324    """Imports needed for the python in this scope. Imports are stored in the module globally
325    to reduce duplicate imports.
326    """
327
328    def __init__(self, content: str | Element, path: str | None = None) -> None:
329        self._path = path or "<python>"
330        self._pos = (0, 0)
331        if isinstance(content, Element):
332            if len(content) > 1 or (
333                len(content) == 1 and not Literal.is_text(content[0])
334            ):
335                # TODO: Custom error
336                raise ValueError(
337                    "Expected python elements to contain one text node or nothing",
338                )
339            if content.position is not None:
340                start = content.position.start
341                self._pos = (start.line, start.column)
342            content = content[0].content
343        content = normalize_indent(content)
344        self.imports = []
345        self.context = {}
346        if len(content) > 0:
347            with EmbeddedTryCatch(path, content, self._pos):
348                self.parse_data(content)
349
350    def __add__(self, _o) -> Embedded:
351        self.imports.extend(_o.imports)
352        self.context.update(_o.context)
353        return self
354
355    def __contains__(self, key: str) -> bool:
356        return key in self.context
357
358    def __getitem__(self, key: str) -> Any:
359        if key in self.context:
360            return self.context[key]
361        elif key in self.imports:
362            return __IMPORTS__[key]
363
364        raise KeyError(f"Key is not in Embedded context or imports: {key}")
365
366    def split_contexts(self, content: str) -> tuple[list[str], list[EmbeddedImport]]:
367        re_context = re.compile(r"class.+|def.+")
368        re_import = re.compile(
369            r"from (?P<key>.+) import (?P<values>.+)|import (?P<value>.+)",
370        )
371
372        imports = []
373        blocks = []
374        current = []
375
376        lines = content.split("\n")
377        i = 0
378        while i < len(lines):
379            imp_match = re_import.match(lines[i])
380            if imp_match is not None:
381                data = imp_match.groupdict()
382                imports.append(
383                    EmbeddedImport(data["key"] or data["value"], data["values"])
384                )
385            elif re_context.match(lines[i]) is not None:
386                blocks.append("\n".join(current))
387                current = [lines[i]]
388                i += 1
389                while i < len(lines) and lines[i].startswith(" "):
390                    current.append(lines[i])
391                    i += 1
392                blocks.append("\n".join(current))
393                current = []
394            else:
395                current.append(lines[i])
396            if i < len(lines):
397                i += 1
398
399        if len(current) > 0:
400            blocks.append("\n".join(current))
401
402        return blocks, imports
403
404    def parse_data(self, content: str):
405        blocks, self.imports = self.split_contexts(content)
406
407        local_env = {}
408        global_env = {key: value for _import in self.imports for key, value in _import}
409        context = {**global_env}
410
411        for block in blocks:
412            exec_val = compile(block, self._path, "exec")
413            exec(exec_val, global_env, local_env)
414            context.update(local_env)
415            # update global env with found locals so they can be used inside methods and classes
416            global_env.update(local_env)
417
418        self.context = context

Logic for parsing and storing locals and imports of dynamic python code.

Embedded(content: str | phml.nodes.Element, path: str | None = None)
328    def __init__(self, content: str | Element, path: str | None = None) -> None:
329        self._path = path or "<python>"
330        self._pos = (0, 0)
331        if isinstance(content, Element):
332            if len(content) > 1 or (
333                len(content) == 1 and not Literal.is_text(content[0])
334            ):
335                # TODO: Custom error
336                raise ValueError(
337                    "Expected python elements to contain one text node or nothing",
338                )
339            if content.position is not None:
340                start = content.position.start
341                self._pos = (start.line, start.column)
342            content = content[0].content
343        content = normalize_indent(content)
344        self.imports = []
345        self.context = {}
346        if len(content) > 0:
347            with EmbeddedTryCatch(path, content, self._pos):
348                self.parse_data(content)
context: dict[str, typing.Any]

Variables and locals found in the python code block.

Imports needed for the python in this scope. Imports are stored in the module globally to reduce duplicate imports.

def split_contexts( self, content: str) -> tuple[list[str], list[phml.embedded.EmbeddedImport]]:
366    def split_contexts(self, content: str) -> tuple[list[str], list[EmbeddedImport]]:
367        re_context = re.compile(r"class.+|def.+")
368        re_import = re.compile(
369            r"from (?P<key>.+) import (?P<values>.+)|import (?P<value>.+)",
370        )
371
372        imports = []
373        blocks = []
374        current = []
375
376        lines = content.split("\n")
377        i = 0
378        while i < len(lines):
379            imp_match = re_import.match(lines[i])
380            if imp_match is not None:
381                data = imp_match.groupdict()
382                imports.append(
383                    EmbeddedImport(data["key"] or data["value"], data["values"])
384                )
385            elif re_context.match(lines[i]) is not None:
386                blocks.append("\n".join(current))
387                current = [lines[i]]
388                i += 1
389                while i < len(lines) and lines[i].startswith(" "):
390                    current.append(lines[i])
391                    i += 1
392                blocks.append("\n".join(current))
393                current = []
394            else:
395                current.append(lines[i])
396            if i < len(lines):
397                i += 1
398
399        if len(current) > 0:
400            blocks.append("\n".join(current))
401
402        return blocks, imports
def parse_data(self, content: str):
404    def parse_data(self, content: str):
405        blocks, self.imports = self.split_contexts(content)
406
407        local_env = {}
408        global_env = {key: value for _import in self.imports for key, value in _import}
409        context = {**global_env}
410
411        for block in blocks:
412            exec_val = compile(block, self._path, "exec")
413            exec(exec_val, global_env, local_env)
414            context.update(local_env)
415            # update global env with found locals so they can be used inside methods and classes
416            global_env.update(local_env)
417
418        self.context = context
def update_ast_node_pos(dest, source):
436def update_ast_node_pos(dest, source):
437    """Assign lineno, end_lineno, col_offset, and end_col_offset
438    from a source python ast node to a destination python ast node.
439    """
440    dest.lineno = source.lineno
441    dest.end_lineno = source.end_lineno
442    dest.col_offset = source.col_offset
443    dest.end_col_offset = source.end_col_offset

Assign lineno, end_lineno, col_offset, and end_col_offset from a source python ast node to a destination python ast node.

def exec_embedded(code: str, _path: str | None = None, **context: Any) -> Any:
449def exec_embedded(code: str, _path: str | None = None, **context: Any) -> Any:
450    """Execute embedded python and return the extracted value. This is the last
451    assignment in the embedded python. The embedded python must have the last line as a value
452    or an assignment.
453
454    Note:
455        No local or global variables will be retained from the embedded python code.
456
457    Args:
458        code (str): The embedded python code.
459        **context (Any): The additional context to provide to the embedded python.
460
461    Returns:
462        Any: The value of the last assignment or value defined
463    """
464    from phml.utilities import blank
465
466    context = {
467        "blank": blank,
468        **context,
469    }
470
471    # last line must be an assignment or the value to be used
472    with EmbeddedTryCatch(_path, code):
473        code = normalize_indent(code)
474        AST = ast.parse(code)
475        _validate_kwargs(AST, context)
476
477        last = AST.body[-1]
478        returns = [ret for ret in AST.body if isinstance(ret, ast.Return)]
479
480        if len(returns) > 0:
481            last = returns[0]
482            idx = AST.body.index(last)
483
484            n_expr = ast.Name(id=RESULT, ctx=ast.Store())
485            n_assign = ast.Assign(targets=[n_expr], value=last.value)
486
487            update_ast_node_pos(dest=n_expr, source=last)
488            update_ast_node_pos(dest=n_assign, source=last)
489
490            AST.body = [*AST.body[:idx], n_assign]
491        elif isinstance(last, ast.Expr):
492            n_expr = ast.Name(id=RESULT, ctx=ast.Store())
493            n_assign = ast.Assign(targets=[n_expr], value=last.value)
494
495            update_ast_node_pos(dest=n_expr, source=last)
496            update_ast_node_pos(dest=n_assign, source=last)
497
498            AST.body[-1] = n_assign
499        elif isinstance(last, ast.Assign):
500            n_expr = ast.Name(id=RESULT, ctx=ast.Store())
501            update_ast_node_pos(dest=n_expr, source=last)
502            last.targets.append(n_expr)
503
504        ccode = compile(AST, "_phml_embedded_", "exec")
505        local_env = {}
506        exec(ccode, {**context}, local_env)
507
508        if isinstance(local_env[RESULT], str):
509            return escape(local_env[RESULT], **ESCAPE_OPTIONS)
510        return local_env[RESULT]

Execute embedded python and return the extracted value. This is the last assignment in the embedded python. The embedded python must have the last line as a value or an assignment.

Note

No local or global variables will be retained from the embedded python code.

Args
  • code (str): The embedded python code.
  • **context (Any): The additional context to provide to the embedded python.
Returns

Any: The value of the last assignment or value defined

def exec_embedded_blocks(code: str, _path: str = '', **context: dict[str, typing.Any]):
513def exec_embedded_blocks(code: str, _path: str = "", **context: dict[str, Any]):
514    """Execute embedded python inside `{{}}` blocks. The resulting values are subsituted
515    in for the found blocks.
516
517    Note:
518        No local or global variables will be retained from the embedded python code.
519
520    Args:
521        code (str): The embedded python code.
522        **context (Any): The additional context to provide to the embedded python.
523
524    Returns:
525        str: The value of the passed in string with the python blocks replaced.
526    """
527
528    result = [""]
529    data = []
530    next_block = re.search(r"\{\{", code)
531    while next_block is not None:
532        start = next_block.start()
533        if start > 0:
534            result[-1] += code[:start]
535        code = code[start + 2 :]
536
537        balance = 2
538        index = 0
539        while balance > 0 and index < len(code):
540            if code[index] == "}":
541                balance -= 1
542            elif code[index] == "{":
543                balance += 1
544            index += 1
545
546        result.append("")
547        data.append(
548            str(
549                exec_embedded(
550                    code[: index - 2].strip(),
551                    _path + f" block #{len(data)+1}",
552                    **context,
553                ),
554            ),
555        )
556        code = code[index:]
557        next_block = re.search(r"(?<!\\)\{\{", code)
558
559    if len(code) > 0:
560        result[-1] += code
561
562    if len(data) != len(result) - 1:
563        raise ValueError(
564            f"Not enough data to replace inline python blocks: expected {len(result) - 1} but there was {len(data)}"
565        )
566
567    def merge(dest: list, source: list) -> list:
568        """Merge source into dest. For every item in source place each item between items of dest.
569        If there is more items in source the spaces between items in dest then the extra items in source
570        are ignored.
571
572        Example:
573            dest = [1, 2, 3]
574            source = ["red", "blue", "green"]
575            merge(dest, source) == [1, "red", 2, "blue", 3]
576
577            or
578
579            dest = [1, 2, 3]
580            source = ["red"]
581            merge(dest, source) == [1, "red", 2, 3]
582        """
583        combination = []
584        for f_item, s_item in zip(dest, source):
585            combination.extend([f_item, s_item])
586
587        idx = len(combination) // 2
588        if idx < len(dest):
589            combination.extend(dest[idx:])
590        return combination
591
592    return "".join(merge(result, data))

Execute embedded python inside {{}} blocks. The resulting values are subsituted in for the found blocks.

Note

No local or global variables will be retained from the embedded python code.

Args
  • code (str): The embedded python code.
  • **context (Any): The additional context to provide to the embedded python.
Returns

str: The value of the passed in string with the python blocks replaced.