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.

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/beartype/beartype/pull/88?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beartype) Report
> Merging [#88](https://codecov.io/gh/beartype/beartype/pull/88?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beartype) (6b44680) into [main](https://codecov.io/gh/beartype/beartype/commit/7149ca7bacc44a9aca86a4f9212dff48ebc57ca4?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beartype) (7149ca7) will **decrease** coverage by `0.06%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/beartype/beartype/pull/88/graphs/tree.svg?width=650&height=150&src=pr&token=E6F4YSY9ZQ&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beartype)](https://codecov.io/gh/beartype/beartype/pull/88?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beartype)

```diff
@@            Coverage Diff             @@
##             main      #88      +/-   ##
==========================================
- Coverage   96.68%   96.62%   -0.07%     
==========================================
  Files         118      118              
  Lines        4379     4388       +9     
==========================================
+ Hits         4234     4240       +6     
- Misses        145      148       +3     
```


| [Impacted Files](https://codecov.io/gh/beartype/beartype/pull/88?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beartype) | Coverage Δ | |
|---|---|---|
| [beartype/\_util/func/utilfuncmake.py](https://codecov.io/gh/beartype/beartype/pull/88/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beartype#diff-YmVhcnR5cGUvX3V0aWwvZnVuYy91dGlsZnVuY21ha2UucHk=) | `98.18% <100.00%> (+0.35%)` | :arrow_up: |
| [beartype/cave/\_cavelib.py](https://codecov.io/gh/beartype/beartype/pull/88/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beartype#diff-YmVhcnR5cGUvY2F2ZS9fY2F2ZWxpYi5weQ==) | `90.24% <0.00%> (-4.88%)` | :arrow_down: |
| [beartype/\_util/mod/utilmodtest.py](https://codecov.io/gh/beartype/beartype/pull/88/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beartype#diff-YmVhcnR5cGUvX3V0aWwvbW9kL3V0aWxtb2R0ZXN0LnB5) | `89.58% <0.00%> (-2.09%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/beartype/beartype/pull/88?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beartype).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beartype)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/beartype/beartype/pull/88?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beartype). Last update [7149ca7...6b44680](https://codecov.io/gh/beartype/beartype/pull/88?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beartype). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beartype).

posita: @TeamSpen210 did the heavy lifting here (see #84). I just typed it in.

So this is neat:

``` python
# test.py
from beartype import beartype, BeartypeConf

@beartype(conf=BeartypeConf(is_print_wrapper_code=True))
def foo(i: int) -> str:
    breakpoint()
    return str(i)

print(foo(0))
```

``` sh
python test.py
(line 0001) def foo(
(line 0002)     *args,
(line 0003)     __beartype_func=__beartype_func, # is <function foo at 0x106c249d0>
…
(line 0041)             )
(line 0042)
(line 0043)     return __beartype_pith_0
> /…/test.py(7)foo()
-> return str(i)
(Pdb) bt
  /…/test.py(9)<module>()
-> print(foo(0))
  <@beartype(foo) at 0x106c249d0>(29)foo()
-> __beartype_pith_0 = __beartype_func(*args, **kwargs)
> /…/test.py(7)foo()
-> return str(i)
(Pdb) u
> <@beartype(foo) at 0x106c249d0>(29)foo()
-> __beartype_pith_0 = __beartype_func(*args, **kwargs)
(Pdb) l
 24                     pith_value=__beartype_pith_0,
 25                 )
 26
 27         # Call this function with all passed parameters and localize the value
 28         # returned from this call.
 29  ->     __beartype_pith_0 = __beartype_func(*args, **kwargs)
 30
 31         # Noop required to artificially increase indentation level. Note that
 32         # CPython implicitly optimizes this conditional away. Isn't that nice?
 33         if True:
 34             # Type-check this passed parameter or return value against this
(Pdb) c
0
```
posita: I have no idea what [that error](https://github.com/beartype/beartype/runs/4968911087?check_suite_focus=true) is about. 😕
leycec: **This so hot.** Plentiful bear hugs to the crack commando squad that made this happen. What cannot @posita and @TeamSpen210 do when their formidable powers combine? We are now finding out.

I'd better merge this ASAP before I accidentally break everything by drunkenly stumbling back into the `utilfuncmake` submodule on a frostbitten Saturday evening. Three questions for the restless crowd angrily thumping the armrests of their La-Z-Boys:

* The option name `is_print_wrapper_code`. It is verbose, overly specific, and I already grow weary of seeing it everywhere. But laziness prevails, because I else have yet to release `beartype 0.10.0` or publicly document anything. This PR generalizes that option to *also* cache that code for debuggability – which is great, because debuggability was the entire point of that option. Right? You are now wondering: "Where is the question we were promised." Okay, already! Down, angry rabble! Down! The question is:
  * Should we just name this option `is_debug` instead? My initial reasoning for that name was that we'd have different kinds of debugging options – like, say, `is_log_wrapper_code` or `is_linecache_wrapper_code`. That now seems like gross overkill. If anyone furiously demands deeper debugging options, we can always define those later. So... `is_debug`? Thumbs up? Facepalm? Double facepalm, even? :grimacing: 
* Kinda afraid to ask what's going on down below, but gonna ask anyway. What's going on down below? ↓
``` python
        finalize(func, lambda: linecache.cache.pop(linecache_file_name, None))
```
* This seem kinda sus and I am squinting af:

``` python
       linecache_file_name = f"<@beartype({func_name}) at {id(func_wrapped):#x}>"
       ...
 
       # FIXME: Do we need a loop here to avoid name clashes (a la
        # _make_method in
        # <https://github.com/python-attrs/attrs/blob/main/src/attr/_make.py>)?
        # Probobably unlikely, but there is this (from
        # <https://docs.python.org/3/library/functions.html#id>): "Two objects
        # with non-overlapping lifetimes may have the same id() value."
        linecache.cache[linecache_file_name] = (  # type: ignore[assignment]
```

As you astutely note, object IDs are technically ambiguous across the lifecycle of a Python process. Since we can't disambiguate on that, let's disambiguate on the `func_name` instead! Specifically, let's conditionally expand the assignment of `linecache_file_name` to prefer a fully-qualified function name cobbled together from the passed wrapper function if passed: e.g.,

``` python
       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}>"
```

Since `@beartype` always passes the currently decorated callable as the `func_wrapped` parameter to `make_func`, this *should* have the intended effect of disambiguating cache entries on fully-qualified callable names – which *should* be fully disambiguous across the lifecycle of a Python process (assuming no shenanigans, because let's not even go there, because my forehead capillary is already bursting out).
posita: ## On `is_debug`

> * Should we just name this option `is_debug` instead? My initial reasoning for that name was that we'd have different kinds of debugging options – like, say, `is_log_wrapper_code` or `is_linecache_wrapper_code`. That now seems like gross overkill. If anyone furiously demands deeper debugging options, we can always define those later. So... `is_debug`? Thumbs up? Facepalm? Double facepalm, even? 😬

My vote is double secret probation triple-lindy thumbs up. #90 is up to see what that looks like. If that works for you, I can rebase this PR onto that one.

## On `finalize`

> * Kinda afraid to ask what's going on down below, but gonna ask anyway. What's going on down below? ↓
> 
> ```python
>         finalize(func, lambda: linecache.cache.pop(linecache_file_name, None))
> ```

You are right to inquire. Your allusion to La-Z-Boys could describe me in ~writing that~ _obfuscating what was going on there_. I have attempted to make it a bit more accessible. I'll reproduce that attempt here:

``` python
        # 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)
```

## Regarding uniqueness of file names

I like your proposal and adopted it with vigor (and added a comment).

## Side trip: Should "exec" be "single" instead? Does it matter?

Regarding this…

``` python
        #FIXME: Should "exec" be "single" instead? Does it matter? Is there any
        #performance gap between the two?
```

While I was messing with all of this, I figured I'd try to answer that because I was curious myself. In my superficial testing, `"exec"` is at worst about 1 µs slower than `"single"`, so not really a performance issue.

Here's my IPython test script:

``` python
# foo.ipy
code = """
class Foo:
  def __init__(self):
    self._foo = foo(1)
  @property
  def foo(self) -> int:
    return int(self._foo)
"""
%timeit compile(code, "foo.py", "exec")
%timeit compile(code, "foo.py", "single")
```

These are typical for my laptop:

``` sh
% ipython foo.ipy
38.9 µs ± 307 ns per loop (mean ± std. dev. of 7 runs, 10,000 loops each)
37.4 µs ± 444 ns per loop (mean ± std. dev. of 7 runs, 10,000 loops each)
% ipython foo.ipy
38.5 µs ± 454 ns per loop (mean ± std. dev. of 7 runs, 10,000 loops each)
39 µs ± 1.32 µs per loop (mean ± std. dev. of 7 runs, 10,000 loops each)
% ipython foo.ipy
40.6 µs ± 1.39 µs per loop (mean ± std. dev. of 7 runs, 10,000 loops each)
39.8 µs ± 1.13 µs per loop (mean ± std. dev. of 7 runs, 10,000 loops each)
```

The only other reason I could think of to use `"single"` is that it acts as a "safeguard" of sorts by limiting the generated code to defining a single thing. For example, `"single"` will fail on the following, where `"exec"` won't:

``` python
class Foo:
  def __init__(self):
    self._foo = foo(1)
  @property
  def foo(self) -> int:
    return int(self._foo)
def foo(a: int) -> str:  # <-- "single" will see this second thing as a syntax error
  return str(a)
```

I removed your `FIXME` and kept the `"exec"`, but let me know if I should change it to `"single"` and/or add a comment explaining any reasoning.
TeamSpen210: `weakref.finalize()` is a tool which uses the weakref system to effectively add on a `__del__()` method to an object - it stores a callable, when the object dies it gets called. So this ensures if the original function is deleted (say you're beartyping a closure, perhaps?) we go and try to clear the linecache entry. `pop`'s desirable to ensure if someone else cleared the cache or something it doesn't error.

Ah, ninjaed.
posita: Rebased in light of #90 landing.
leycec: ![the impossible made possible](https://c.tenor.com/O2dd9U8FmFAAAAAd/back-to-school-triple-lindy.gif)

When a brilliance is so bright that it caustically sears itself into the mucosal lining of the back of your retinas, it's a safe bet @posita and @TeamSpen210 teamed up yet again to radically transform our humble backwater world into the utopian Niven ring of tomorrow, today.

Thanks *sooo* much for the tremendous contribution. Hot off the presses:

> **Crack :bear: Squad Crushes Another Tough Nut.**

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``