|
3 | 3 | from inspect import iscoroutinefunction |
4 | 4 | from inspect import isgeneratorfunction |
5 | 5 | import sys |
| 6 | +from types import CodeType |
6 | 7 | from types import FrameType |
7 | 8 | from types import FunctionType |
8 | 9 | from types import TracebackType |
|
24 | 25 |
|
25 | 26 | T = t.TypeVar("T") |
26 | 27 |
|
| 28 | +# ============================================================================ |
| 29 | +# Lazy wrapping machinery: allows deferring expensive bytecode instrumentation |
| 30 | +# until the first time a function is actually called. |
| 31 | +# ============================================================================ |
| 32 | + |
| 33 | +_lazy_registry: t.Dict[CodeType, "LazyMeta"] = {} |
| 34 | + |
| 35 | + |
| 36 | +class LazyMeta: |
| 37 | + """Metadata for a lazily-wrapped function.""" |
| 38 | + |
| 39 | + __slots__ = ("func", "builder", "lock", "initialized") |
| 40 | + |
| 41 | + def __init__(self, func: FunctionType, builder: t.Callable[[FunctionType], CodeType]): |
| 42 | + self.func = func # the original function object |
| 43 | + self.builder = builder # callable: builder(func) -> CodeType |
| 44 | + self.initialized = False |
| 45 | + |
| 46 | + |
| 47 | +def __lazy_trampoline_entry(*args, **kwargs): |
| 48 | + """ |
| 49 | + Called from the trampoline code on first invocation. |
| 50 | + Uses the current frame's code object to find the right LazyMeta, |
| 51 | + builds the heavy bytecode, and swaps it in place. |
| 52 | + """ |
| 53 | + # Get the caller's frame (the trampoline frame) |
| 54 | + code = sys._getframe(1).f_code |
| 55 | + meta = _lazy_registry[code] |
| 56 | + |
| 57 | + if not meta.initialized: |
| 58 | + if not meta.initialized: |
| 59 | + # Build heavy code from the original pre-wrapped function |
| 60 | + new_code = meta.builder(meta.func) |
| 61 | + # Swap the code on the SAME function object frameworks already hold |
| 62 | + meta.func.__code__ = new_code |
| 63 | + meta.initialized = True |
| 64 | + # Drop the registry entry to free memory |
| 65 | + _lazy_registry.pop(code, None) |
| 66 | + |
| 67 | + # Now call the function again; this time it runs the heavy code |
| 68 | + return meta.func(*args, **kwargs) |
| 69 | + |
| 70 | + |
| 71 | +def _make_trampoline_code(template_code: CodeType) -> CodeType: |
| 72 | + """ |
| 73 | + Build a tiny code object that: |
| 74 | + - takes *args, **kwargs |
| 75 | + - calls the global __lazy_trampoline_entry(*args, **kwargs) |
| 76 | + - returns its value |
| 77 | +
|
| 78 | + Note: This only works for functions with 0 freevars. |
| 79 | + """ |
| 80 | + bc = Bytecode() |
| 81 | + bc.name = template_code.co_name |
| 82 | + bc.filename = template_code.co_filename |
| 83 | + bc.first_lineno = template_code.co_firstlineno |
| 84 | + |
| 85 | + # Function signature: accepts *args, **kwargs |
| 86 | + bc.argcount = 0 |
| 87 | + bc.posonlyargcount = 0 |
| 88 | + bc.kwonlyargcount = 0 |
| 89 | + bc.flags = bytecode.CompilerFlags.VARARGS | bytecode.CompilerFlags.VARKEYWORDS |
| 90 | + |
| 91 | + # Declare locals for *args, **kwargs |
| 92 | + bc.argnames = ["args", "kwargs"] |
| 93 | + |
| 94 | + # No freevars or cellvars |
| 95 | + bc.freevars = [] |
| 96 | + bc.cellvars = [] |
| 97 | + |
| 98 | + # Call the global entry: __lazy_trampoline_entry(*args, **kwargs) |
| 99 | + if sys.version_info >= (3, 13): |
| 100 | + # Python 3.13+ |
| 101 | + bc.extend( |
| 102 | + [ |
| 103 | + bytecode.Instr("LOAD_GLOBAL", (True, "__lazy_trampoline_entry")), # (True = NULL + func) |
| 104 | + bytecode.Instr("LOAD_FAST", "args"), |
| 105 | + bytecode.Instr("BUILD_MAP", 0), |
| 106 | + bytecode.Instr("LOAD_FAST", "kwargs"), |
| 107 | + bytecode.Instr("DICT_MERGE", 1), |
| 108 | + bytecode.Instr("CALL_FUNCTION_EX", 1), |
| 109 | + bytecode.Instr("RETURN_VALUE"), |
| 110 | + ] |
| 111 | + ) |
| 112 | + elif sys.version_info >= (3, 11): |
| 113 | + # Python 3.11-3.12 |
| 114 | + bc.extend( |
| 115 | + [ |
| 116 | + bytecode.Instr("PUSH_NULL"), |
| 117 | + bytecode.Instr("LOAD_GLOBAL", (False, "__lazy_trampoline_entry")), |
| 118 | + bytecode.Instr("LOAD_FAST", "args"), |
| 119 | + bytecode.Instr("LOAD_FAST", "kwargs"), |
| 120 | + bytecode.Instr("CALL_FUNCTION_EX", 1), |
| 121 | + bytecode.Instr("RETURN_VALUE"), |
| 122 | + ] |
| 123 | + ) |
| 124 | + else: |
| 125 | + # Python 3.10 and earlier |
| 126 | + bc.extend( |
| 127 | + [ |
| 128 | + bytecode.Instr("LOAD_GLOBAL", "__lazy_trampoline_entry"), |
| 129 | + bytecode.Instr("LOAD_FAST", "args"), |
| 130 | + bytecode.Instr("LOAD_FAST", "kwargs"), |
| 131 | + bytecode.Instr("CALL_FUNCTION_EX", 1), |
| 132 | + bytecode.Instr("RETURN_VALUE"), |
| 133 | + ] |
| 134 | + ) |
| 135 | + |
| 136 | + return bc.to_code() |
| 137 | + |
| 138 | + |
27 | 139 | # This module implements utilities for wrapping a function with a context |
28 | 140 | # manager. The rough idea is to re-write the function's bytecode to look like |
29 | 141 | # this: |
@@ -405,6 +517,10 @@ def extract(cls, f: FunctionType) -> "WrappingContext": |
405 | 517 | def wrap(self) -> None: |
406 | 518 | t.cast(_UniversalWrappingContext, _UniversalWrappingContext.wrapped(self.__wrapped__)).register(self) |
407 | 519 |
|
| 520 | + def wrap_lazy(self) -> None: |
| 521 | + """Install lazy wrapping that defers bytecode instrumentation until first call.""" |
| 522 | + t.cast(_UniversalWrappingContext, _UniversalWrappingContext.wrapped_lazy(self.__wrapped__)).register(self) |
| 523 | + |
408 | 524 | def unwrap(self) -> None: |
409 | 525 | f = self.__wrapped__ |
410 | 526 |
|
@@ -475,6 +591,16 @@ def register(self, context: WrappingContext) -> None: |
475 | 591 | self._contexts.append(context) |
476 | 592 | self._contexts.sort(key=lambda c: c.__priority__) |
477 | 593 |
|
| 594 | + @classmethod |
| 595 | + def wrapped_lazy(cls, f: FunctionType) -> "_UniversalWrappingContext": |
| 596 | + """Create a universal wrapping context with lazy bytecode instrumentation.""" |
| 597 | + if cls.is_wrapped(f): |
| 598 | + context = cls.extract(f) |
| 599 | + else: |
| 600 | + context = cls(f) |
| 601 | + context.wrap_lazy() |
| 602 | + return context |
| 603 | + |
478 | 604 | def unregister(self, context: WrappingContext) -> None: |
479 | 605 | try: |
480 | 606 | self._contexts.remove(context) |
@@ -541,86 +667,123 @@ def extract(cls, f: FunctionType) -> "_UniversalWrappingContext": |
541 | 667 | raise ValueError("Function is not wrapped") |
542 | 668 | return t.cast(_UniversalWrappingContext, t.cast(ContextWrappedFunction, f).__dd_context_wrapped__) |
543 | 669 |
|
544 | | - if sys.version_info >= (3, 11): |
545 | | - |
546 | | - def wrap(self) -> None: |
547 | | - f = self.__wrapped__ |
| 670 | + def _build_wrapped_code(self, f: FunctionType) -> CodeType: |
| 671 | + """ |
| 672 | + Build the heavy instrumented bytecode for a function. |
| 673 | + This is extracted from wrap() to enable lazy wrapping. |
| 674 | + """ |
| 675 | + bc = Bytecode.from_code(f.__code__) |
548 | 676 |
|
549 | | - if self.is_wrapped(f): |
550 | | - raise ValueError("Function already wrapped") |
| 677 | + # Prefix every return |
| 678 | + i = 0 |
| 679 | + while i < len(bc): |
| 680 | + instr = bc[i] |
| 681 | + try: |
| 682 | + if instr.name == "RETURN_VALUE": |
| 683 | + return_code = CONTEXT_RETURN.bind({"context_return": self.__return__}, lineno=instr.lineno) |
| 684 | + elif sys.version_info >= (3, 12) and instr.name == "RETURN_CONST": # Python 3.12+ |
| 685 | + return_code = CONTEXT_RETURN_CONST.bind( |
| 686 | + {"context_return": self.__return__, "value": instr.arg}, lineno=instr.lineno |
| 687 | + ) |
| 688 | + else: |
| 689 | + return_code = [] |
551 | 690 |
|
552 | | - bc = Bytecode.from_code(f.__code__) |
| 691 | + bc[i:i] = return_code |
| 692 | + i += len(return_code) |
| 693 | + except AttributeError: |
| 694 | + # Not an instruction |
| 695 | + pass |
| 696 | + i += 1 |
553 | 697 |
|
554 | | - # Prefix every return |
| 698 | + # Search for the RESUME instruction |
| 699 | + for i, instr in enumerate(bc, 1): |
| 700 | + try: |
| 701 | + if instr.name == "RESUME": |
| 702 | + break |
| 703 | + except AttributeError: |
| 704 | + # Not an instruction |
| 705 | + pass |
| 706 | + else: |
555 | 707 | i = 0 |
556 | | - while i < len(bc): |
557 | | - instr = bc[i] |
558 | | - try: |
559 | | - if instr.name == "RETURN_VALUE": |
560 | | - return_code = CONTEXT_RETURN.bind({"context_return": self.__return__}, lineno=instr.lineno) |
561 | | - elif sys.version_info >= (3, 12) and instr.name == "RETURN_CONST": # Python 3.12+ |
562 | | - return_code = CONTEXT_RETURN_CONST.bind( |
563 | | - {"context_return": self.__return__, "value": instr.arg}, lineno=instr.lineno |
564 | | - ) |
565 | | - else: |
566 | | - return_code = [] |
567 | 708 |
|
568 | | - bc[i:i] = return_code |
569 | | - i += len(return_code) |
570 | | - except AttributeError: |
571 | | - # Not an instruction |
572 | | - pass |
573 | | - i += 1 |
| 709 | + bc[i:i] = CONTEXT_HEAD.bind({"context_enter": self.__enter__}, lineno=f.__code__.co_firstlineno) |
574 | 710 |
|
575 | | - # Search for the RESUME instruction |
576 | | - for i, instr in enumerate(bc, 1): |
577 | | - try: |
578 | | - if instr.name == "RESUME": |
| 711 | + # Wrap every line outside a try block |
| 712 | + except_label = bytecode.Label() |
| 713 | + first_try_begin = last_try_begin = bytecode.TryBegin(except_label, push_lasti=True) |
| 714 | + |
| 715 | + i = 0 |
| 716 | + while i < len(bc): |
| 717 | + instr = bc[i] |
| 718 | + if isinstance(instr, bytecode.TryBegin) and last_try_begin is not None: |
| 719 | + bc.insert(i, bytecode.TryEnd(last_try_begin)) |
| 720 | + last_try_begin = None |
| 721 | + i += 1 |
| 722 | + elif isinstance(instr, bytecode.TryEnd): |
| 723 | + j = i + 1 |
| 724 | + while j < len(bc) and not isinstance(bc[j], bytecode.TryBegin): |
| 725 | + if isinstance(bc[j], bytecode.Instr): |
| 726 | + last_try_begin = bytecode.TryBegin(except_label, push_lasti=True) |
| 727 | + bc.insert(i + 1, last_try_begin) |
579 | 728 | break |
580 | | - except AttributeError: |
581 | | - # Not an instruction |
582 | | - pass |
583 | | - else: |
584 | | - i = 0 |
| 729 | + j += 1 |
| 730 | + i += 1 |
| 731 | + i += 1 |
585 | 732 |
|
586 | | - bc[i:i] = CONTEXT_HEAD.bind({"context_enter": self.__enter__}, lineno=f.__code__.co_firstlineno) |
| 733 | + bc.insert(0, first_try_begin) |
587 | 734 |
|
588 | | - # Wrap every line outside a try block |
589 | | - except_label = bytecode.Label() |
590 | | - first_try_begin = last_try_begin = bytecode.TryBegin(except_label, push_lasti=True) |
| 735 | + bc.append(bytecode.TryEnd(last_try_begin)) |
| 736 | + bc.append(except_label) |
| 737 | + bc.extend(CONTEXT_FOOT.bind({"context_exit": self._exit})) |
591 | 738 |
|
592 | | - i = 0 |
593 | | - while i < len(bc): |
594 | | - instr = bc[i] |
595 | | - if isinstance(instr, bytecode.TryBegin) and last_try_begin is not None: |
596 | | - bc.insert(i, bytecode.TryEnd(last_try_begin)) |
597 | | - last_try_begin = None |
598 | | - i += 1 |
599 | | - elif isinstance(instr, bytecode.TryEnd): |
600 | | - j = i + 1 |
601 | | - while j < len(bc) and not isinstance(bc[j], bytecode.TryBegin): |
602 | | - if isinstance(bc[j], bytecode.Instr): |
603 | | - last_try_begin = bytecode.TryBegin(except_label, push_lasti=True) |
604 | | - bc.insert(i + 1, last_try_begin) |
605 | | - break |
606 | | - j += 1 |
607 | | - i += 1 |
608 | | - i += 1 |
| 739 | + # Link the function to its original code object so that we can retrieve |
| 740 | + # it later if required. |
| 741 | + link_function_to_code(f.__code__, f) |
609 | 742 |
|
610 | | - bc.insert(0, first_try_begin) |
| 743 | + return bc.to_code() |
611 | 744 |
|
612 | | - bc.append(bytecode.TryEnd(last_try_begin)) |
613 | | - bc.append(except_label) |
614 | | - bc.extend(CONTEXT_FOOT.bind({"context_exit": self._exit})) |
| 745 | + def wrap_lazy(self) -> None: |
| 746 | + """ |
| 747 | + Install lazy wrapping: replace function's __code__ with a trampoline |
| 748 | + that defers the expensive bytecode instrumentation until first call. |
| 749 | + """ |
| 750 | + f = self.__wrapped__ |
| 751 | + |
| 752 | + if self.is_wrapped(f): |
| 753 | + raise ValueError("Function already wrapped") |
| 754 | + |
| 755 | + # Lazy wrapping doesn't work with closures (functions with freevars) |
| 756 | + # because we can't replace __code__ with different freevar counts. |
| 757 | + # Fall back to eager wrapping in this case. |
| 758 | + if len(f.__code__.co_freevars) > 0: |
| 759 | + return self.wrap() |
| 760 | + |
| 761 | + # Mark the function as wrapped immediately (before the trampoline) |
| 762 | + t.cast(ContextWrappedFunction, f).__dd_context_wrapped__ = self |
| 763 | + |
| 764 | + # Create a trampoline code object with matching freevars |
| 765 | + tramp_code = _make_trampoline_code(f.__code__) |
| 766 | + |
| 767 | + # Register the builder that will be called on first invocation |
| 768 | + _lazy_registry[tramp_code] = LazyMeta(f, self._build_wrapped_code) |
| 769 | + |
| 770 | + # Swap in the trampoline (cheap operation) |
| 771 | + f.__code__ = tramp_code |
| 772 | + |
| 773 | + if sys.version_info >= (3, 11): |
| 774 | + |
| 775 | + def wrap(self) -> None: |
| 776 | + """Eagerly wrap the function with full bytecode instrumentation.""" |
| 777 | + f = self.__wrapped__ |
| 778 | + |
| 779 | + if self.is_wrapped(f): |
| 780 | + raise ValueError("Function already wrapped") |
615 | 781 |
|
616 | 782 | # Mark the function as wrapped by a wrapping context |
617 | 783 | t.cast(ContextWrappedFunction, f).__dd_context_wrapped__ = self |
618 | 784 |
|
619 | | - # Replace the function code with the wrapped code. We also link |
620 | | - # the function to its original code object so that we can retrieve |
621 | | - # it later if required. |
622 | | - link_function_to_code(f.__code__, f) |
623 | | - f.__code__ = bc.to_code() |
| 785 | + # Build and install the heavy wrapped code immediately |
| 786 | + f.__code__ = self._build_wrapped_code(f) |
624 | 787 |
|
625 | 788 | def unwrap(self) -> None: |
626 | 789 | f = self.__wrapped__ |
|
0 commit comments