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))
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.
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.
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
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
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
Inherited Members
- builtins.dict
- get
- setdefault
- pop
- popitem
- keys
- items
- update
- fromkeys
- clear
- copy
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.
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
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.
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.
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.
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)
Imports needed for the python in this scope. Imports are stored in the module globally to reduce duplicate imports.
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
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
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.
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
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.