Let's break these down one-by-one. Python's scoping rules are a bit unhinged sometimes. The basic idea is that if you ever assign to a variable inside of a given function, then Python assumes that that variable is local to that particular function.
def main_function():
b = 'b'
def nested_function():
print(b)
nested_function()
main_function()
Here, you never assign to b inside of nested_function, so Python sees no need to create a new variable. Instead, it inherits the one from its enclosing scope.
def main_function():
b = 'b'
def nested_function():
b = 'value change'
nested_function()
main_function()
Here, you do assign to it, so you get a new variable. The b you assign to inside of nested_function is unrelated to the one inside of main_function. They just happen to share a name.
def main_function():
b = 'b'
def nested_function():
print(b)
b = 'value change'
nested_function()
main_function()
Here, you do assign, so Python makes a new variable inside of the function, and every reference to b inside the nested function refers to that new variable, so it's as if you did this.
def main_function():
b_0 = 'b'
def nested_function():
print(b_1)
b_1 = 'value change'
nested_function()
main_function()
So you're trying to print out b_1 before any value has been assigned to it, hence the error.
If b was a module-level variable, you would use the global keyword to mark it as such inside the function. In your case, it's a local variable; it's just not local to the immediately enclosing function. So we use the nonlocal keyword.
def main_function():
b = 'b'
def nested_function():
nonlocal b
print(b)
b = 'value change'
nested_function()
main_function()
nonlocal b says "I know I'm assigning to b, but I actually mean to use the one from the enclosing scope".