• 
      

    Fix unspy replacing staticmethods and classmethods with plain functions.

    Review Request #15147 — Created July 2, 2026 and submitted

    Information

    kgb
    master

    Reviewers

    kgb

    When spying on a staticmethod or classmethod defined directly on a
    class (passing the method plus owner=), unspy() replaced the
    descriptor on the class with a plain function, instead of restoring the
    original staticmethod or classmethod.

    The cause was in how the spy records the value to restore. When the
    owner does not need patching (a class that owns the attribute directly,
    not slippery, not inherited), the spy stored self.orig_func as the
    restore value. For a staticmethod or classmethod, orig_func is the
    function obtained through attribute access, which has already been
    unwrapped by the descriptor. On unspy(), that unwrapped function was
    assigned back onto the class, discarding the descriptor.

    While spied, everything still worked, because the spy swaps the code
    object in place and the descriptor keeps pointing at the same function.
    The corruption only appeared after unspy(): the attribute was now a
    plain function, so a later call through an instance bound the instance
    as the first positional argument. For a staticmethod this raised a
    TypeError. For a classmethod the instance was silently passed where
    the class was expected.

    We now store the attribute value exactly as defined in the owner
    class dict for this case, matching what the owner-patching path
    already does, so the descriptor is preserved across unspy().

    This also removes a workaround in the test base class that manually
    restored MathClass.class_do_math after each test. That workaround
    existed only to paper over this bug, and it corrupted the classmethod
    descriptor for later tests. One test
    (test_construction_with_call_fake_and_classmethod) depended on that
    corruption, asserting object identity across two classmethod accesses;
    it now compares by equality, which is correct for a classmethod that
    returns a fresh bound method each time.

    • Commented out the fix and saw the new unit tests fail.
    • Ran the full test suite on Python 3.14.
    • Verified all staticmethod and classmethod tests pass in isolation,
      confirming they no longer depend on inter-test state.
    Summary ID
    Fix unspy replacing staticmethods and classmethods with plain functions.
    When spying on a `staticmethod` or `classmethod` defined directly on a class (passing the method plus `owner=`), `unspy()` replaced the descriptor on the class with a plain function, instead of restoring the original staticmethod or classmethod. The cause was in how the spy records the value to restore. When the owner does not need patching (a class that owns the attribute directly, not slippery, not inherited), the spy stored `self.orig_func` as the restore value. For a staticmethod or classmethod, `orig_func` is the function obtained through attribute access, which has already been unwrapped by the descriptor. On `unspy()`, that unwrapped function was assigned back onto the class, discarding the descriptor. While spied, everything still worked, because the spy swaps the code object in place and the descriptor keeps pointing at the same function. The corruption only appeared after `unspy()`: the attribute was now a plain function, so a later call through an instance bound the instance as the first positional argument. For a staticmethod this raised a `TypeError`. For a classmethod the instance was silently passed where the class was expected. We now store the attribute value exactly as defined in the owner class dict for this case, matching what the owner-patching path already does, so the descriptor is preserved across `unspy()`. This also removes a workaround in the test base class that manually restored `MathClass.class_do_math` after each test. That workaround existed only to paper over this bug, and it corrupted the classmethod descriptor for later tests. One test (`test_construction_with_call_fake_and_classmethod`) depended on that corruption, asserting object identity across two classmethod accesses; it now compares by equality, which is correct for a classmethod that returns a fresh bound method each time. Testing Done: - Commented out the fix and saw the new unit tests fail. - Ran the full test suite on Python 3.14. - Verified all staticmethod and classmethod tests pass in isolation, confirming they no longer depend on inter-test state.
    muuzvvoxsvkotspxknnxkttmluwttzvl
    Description From Last Updated

    Should this be assertIs?

    chipx86 chipx86

    This could use SpyOpReturn(0).

    chipx86 chipx86

    Same here.

    chipx86 chipx86
    chipx86
    1. I cannot believe this module full of simple logic and straight-forward behavior would have a bug.

    2. kgb/tests/test_function_spy.py (Diff revision 1)
       
       
      Show all issues

      Should this be assertIs?

      1. As per the comment above this, this explicitly cannot be assertIs because classmethod returns a new bound method each time it's accessed.

    3. kgb/tests/test_function_spy.py (Diff revision 1)
       
       
      Show all issues

      This could use SpyOpReturn(0).

    4. kgb/tests/test_function_spy.py (Diff revision 1)
       
       
      Show all issues

      Same here.

    5. 
        
    david
    chipx86
    1. Ship It!
    2. 
        
    david
    Review request changed
    Status:
    Completed
    Change Summary:
    Pushed to master (639860e)