Repository: beartype/beartype

Issue:
[Feature Request] Debuggable wrapper functions
`@beartype` dynamically wraps each decorated callable with a unique wrapper function efficiently type-checking that callable. That's great, because efficient. But that's also currently invisible to debuggers, because `@beartype` fails to register the Python code underlying these wrapper functions with [the little-known standard `linecache` module](https://docs.python.org/3/library/linecache.html).

This feature request tracks work towards rendering `@beartype` compatible with debuggers. For now, please refer to [this superb writeup by witty QA strongman @posita for a detailed discussion of the exact issue under discussion here – complete with the inevitable working solution infused with wisdom by cautionary AI futurologist @TeamSpen210](https://github.com/beartype/beartype/discussions/84).

Humble gratitude to both @posita and @TeamSpen210 for finally shaming me into doing this thing that desperately needs doing.

Comments:

Patch:
diff --git a/beartype/_util/func/utilfuncmake.py b/beartype/_util/func/utilfuncmake.py
index 24cc669c..2bf3b6de 100644
--- a/beartype/_util/func/utilfuncmake.py
+++ b/beartype/_util/func/utilfuncmake.py
@@ -20,6 +20,8 @@ from beartype._util.text.utiltextmunge import number_lines
 from collections.abc import Callable
 from functools import update_wrapper
 from typing import Optional, Type
+from weakref import finalize
+import linecache
 
 # See the "beartype.cave" submodule for further commentary.
 __all__ = ['STAR_IMPORTS_CONSIDERED_HARMFUL']
@@ -152,33 +154,29 @@ def make_func(
         # Python fails to capture that function (i.e., expose that function to
         # this function) when the locals() dictionary is passed; instead, a
         # unique local dictionary *MUST* be passed.
-        #
-        # Note that the same result may also be achieved via the compile()
-        # builtin and "types.FunctionType" class: e.g.,
-        #     func_code_compiled = compile(
-        #         func_code, "<string>", "exec").co_consts[0]
-        #     return types.FunctionType(
-        #         code=func_code_compiled,
-        #         globals=_GLOBAL_ATTRS,
-        #         argdefs=('__beartype_func', func)
-        #     )
-        #
-        # Since doing so is both more verbose and obfuscatory for no tangible
-        # gain, the current circumspect approach is preferred.
-        exec(func_code, func_globals, func_locals)
-
-        #FIXME: See above.
-        #FIXME: Should "exec" be "single" instead? Does it matter? Is there any
-        #performance gap between the two?
-        # func_code_compiled = compile(
-        #     func_code, func_wrapper_filename, "exec").co_consts[0]
-        # return FunctionType(
-        #     code=func_code_compiled,
-        #     globals=_GLOBAL_ATTRS,
-        #
-        #     #FIXME: This really doesn't seem right, but... *shrug*
-        #     argdefs=tuple(local_attrs.values()),
-        # )
+
+        # Make up a filename for compilation and possibly the linecache entry
+        # (if we make one). A fully-qualified name and ID *should* be unique
+        # for the life of the process.
+        func_full_name = (
+            f"{func_wrapped.__module__}{func_wrapped.__name__}"
+            if func_wrapped else
+            func_name
+        )
+        linecache_file_name = f"<@beartype({func_full_name}) at {id(func_wrapped):#x}>"
+
+        # We use the more verbose and obfuscatory compile() builtin instead of
+        # simply calling exec(func_code, func_globals, func_locals) because
+        # exec does not provide a way to set the resulting function object's
+        # .__code__.co_filename read-only attribute. We can use "single"
+        # instead of "exec" if we are willing to accept that func_code is
+        # constrained to a single statement. In casual testing, there is very
+        # little performance difference (with an imperceptibly slight edge
+        # going to "single").
+        func_code_compiled = compile(
+            func_code, linecache_file_name, "exec")
+        assert func_name not in func_locals
+        exec(func_code_compiled, func_globals, func_locals)
     # If doing so fails for any reason...
     except Exception as exception:
         # Raise an exception suffixed by that function's declaration such that
@@ -235,6 +233,23 @@ def make_func(
         func.__doc__ = func_doc
     # Else, that function is undocumented.
 
+    # Since we went through the trouble of printing its definition, we might
+    # as well make its compiled version debuggable, too.
+    if is_debug:
+        linecache.cache[linecache_file_name] = (  # type: ignore[assignment]
+            len(func_code),  # type: ignore[assignment]  # Y u gotta b diff'rnt Python 3.7? WHY?!
+            None,  # mtime, but should be None to avoid being discarded
+            func_code.splitlines(keepends=True),
+            linecache_file_name,
+        )
+
+        # Define and register a cleanup callback for removing func's linecache
+        # entry if func is ever garbage collected.
+        def remove_linecache_entry_for_func():
+            linecache.cache.pop(linecache_file_name, None)
+
+        finalize(func, remove_linecache_entry_for_func)
+
     # Return that function.
     return func
 


Test patch:
diff --git a/beartype_test/a00_unit/a90_decor/test_decorconf.py b/beartype_test/a00_unit/a90_decor/test_decorconf.py
index 98735bd7..0a33a891 100644
--- a/beartype_test/a00_unit/a90_decor/test_decorconf.py
+++ b/beartype_test/a00_unit/a90_decor/test_decorconf.py
@@ -118,6 +118,52 @@ def test_decor_conf_is_debug(capsys) -> None:
     assert '# is <function _earthquake ' in standard_captured.out
 
 
+def test_decor_conf_is_debug_updates_linecache(capsys) -> None:
+    '''
+    Test the :func:`beartype.beartype` decorator passed the optional ``conf``
+    parameter passed the optional ``is_debug`` parameter results
+    in an updated linecache.
+
+    Parameters
+    ----------
+    capsys
+        :mod:`pytest` fixture enabling standard output and error to be reliably
+        captured and tested against from within unit tests and fixtures.
+
+    Parameters
+    ----------
+    https://docs.pytest.org/en/latest/how-to/capture-stdout-stderr.html#accessing-captured-output-from-a-test-function
+        Official ``capsys`` reference documentation.
+    '''
+
+    # Defer heavyweight imports.
+    from beartype import BeartypeConf, beartype
+    import linecache
+
+    # @beartype subdecorator printing wrapper function definitions.
+    beartype_printing = beartype(conf=BeartypeConf(is_debug=True))
+
+    beartyped_earthquake = beartype_printing(_earthquake)
+
+    # Pytest object freezing the current state of standard output and error as
+    # uniquely written to by this unit test up to this statement.
+    standard_captured = capsys.readouterr()
+    standard_lines = standard_captured.out.splitlines(keepends=True)
+
+    # This is probably overkill, but check to see that we generated lines in
+    # our linecache that correspond to the ones we printed. This a fragile
+    # coupling, but we can relax this later to avoid making those line-by-line
+    # comparisons and just check for the decorated function's filename's
+    # presence in the cache.
+    assert beartyped_earthquake.__code__.co_filename in linecache.cache
+    code_len, mtime, code_lines, code_filename = linecache.cache[beartyped_earthquake.__code__.co_filename]
+    assert mtime is None
+    assert len(code_lines) == len(standard_lines)
+    for code_line, standard_line in zip(code_lines, standard_lines):
+        assert code_line in standard_line
+    assert code_filename == beartyped_earthquake.__code__.co_filename
+
+
 def test_decor_conf_strategy() -> None:
     '''
     Test the :func:`beartype.beartype` decorator passed the optional ``conf``