pythonscopepytestmonkeypatchingpython-nonlocal

Why doesn't the `nonlocal` keyword propogate the outer-scoped variable to the calling module?


So this involves a maybe unusual chain of things:

A.py

from B import call

def make_call():
    print("I'm making a call!")
    call(phone_number="867-5309")

B.py

def call(phone_number):
    pass

test_A.py

import pytest

from A import make_call

@pytest.fixture
def patch_A_call(monkeypatch):
    number = "NOT ENTERED"
    number_list = []
    def call(phone_number):
        nonlocal number
        number = phone_number
        number_list.append(phone_number)

    monkeypatch.setattr("A.call", call)
    return (number, number_list)


def test_make_call(patch_A_call):
    make_call()

    print(f"number: {patch_A_call[0]}")
    print(f"number_list: {patch_A_call[1]}")

What's printed is:

number: NOT ENTERED
number_list: [867-5309]

I expected "867-5309" to be the value for both results.

I know that lists are passed by reference in Python—but I assumed that the nonlocal declaration would pass the variable along down the chain.

Why doesn't it work this way?


Solution

  • If you want to see how call is called, I think a simpler option is to replace it with a Mock object:

    from unittest import mock
    
    from A import make_call
    
    
    @mock.patch('A.call')
    def test_make_call(fake_call):
        make_call()
        assert fake_call.call_args.kwargs['phone_number'] == '867-5309'
    

    Here, we're replacing A.call with a unittest.mock.Mock object. This gets called by make_call, and the call arguments are recorded by the Mock object for later inspection.

    This requires substantially less code.

    Note that I'm using an assert statement here, but you could instead print out or otherwise record the value of phone_number if that's your goal.


    The primary problem with your solution is that your patch_A_call fixture is called once, before your test_make_call method executes.

    So while the nonlocal keyword is working as intended...you never see the result, because that return (number, number_list) statement ran before you made the call to make_call.

    You see the result in the list because a list is a "container" -- you add the number to it when calling make_call, and you can see the result because the returned list is the same object available from inside your patched call method.


    For my solution, we don't have to use mock.patch(); we can rewrite your fixture like this:

    import pytest
    from unittest import mock
    
    from A import make_call
    
    
    @pytest.fixture
    def patch_A_call(monkeypatch):
        call_recorder = mock.Mock()
        monkeypatch.setattr("A.call", call_recorder)
        return call_recorder
    
    
    def test_make_call(patch_A_call):
        make_call()
        assert patch_A_call.call_args.kwargs["phone_number"] == "867-5309"
    

    This accomplishes pretty much the same thing.