Warm-up · Activity 1 of 7
// I2.1 · ~30 min · Intermediate
Scopes and namespaces
After this lesson you can predict which variable a name refers to, change outer variables with global and nonlocal, and fix an UnboundLocalError.
Lesson 1 of 6 in I2 Classes
You will be able to
- Predict which binding a name refers to, following the local, enclosing, global and built-in scopes
- Rebind a module-level name with global and an enclosing name with nonlocal, and know when neither is needed
- Explain why an assignment anywhere in a function makes a name local, and fix the UnboundLocalError it causes
Predict · Activity 2 of 7
Predict before you read on: three functions, one name. What does this print?
x = "global" def outer(): x = "enclosing" def inner(): return x return inner() print(outer(), x)Practice · Activity 3 of 7
Fill in the keyword so that step() changes count in make_counter, and each call returns the next number.
def make_counter(): count = 0 def step(): ____ count count += 1 return count return stepcountPractice · Activity 4 of 7
No global statement this time. What does this print?
names = [] def add(name): names.append(name) add("ada") add("bob") print(names)Practice · Activity 5 of 7
Match each line, written inside a function, to what it does.
Brain teaser · Activity 6 of 7
Brain teaser. The print comes before the assignment. What happens when show() runs?
total = 10 def show(): print(total) total = 20 show()Apply · Activity 7 of 7
Mini-task. Write make_averager(), which returns a function add(value). Each call of add returns the average of every value passed so far: 10, then 20, then 60 give 10.0, 15.0 and 30.0. Keep the running total and count in make_averager, not in global variables.
Check your work against this list
Build it yourself
Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.
Worked example
A debug switch and ticket counters
Three ways a function reaches a name outside itself. enable_debug and log rebind module-level names with global. make_counter returns a closure that keeps its own count with nonlocal, so the two counters do not share a number. The last log call reads len, which Python finds in the built-in scope.
main.py
from collections.abc import Callable
# Three ways a function reaches a name outside itself.
DEBUG = False
calls = 0
def enable_debug() -> None:
global DEBUG
DEBUG = True
def log(message: str) -> None:
global calls
calls += 1
if DEBUG:
print(f"[{calls}] {message}")
def make_counter(start: int) -> Callable[[], int]:
count = start
def step() -> int:
nonlocal count
count += 1
return count
return step
log("not shown: debug is off")
enable_debug()
log("debug is on")
tickets = make_counter(100)
orders = make_counter(0)
print(tickets(), tickets(), orders())
log(f"len is still the built-in: {len('abc')}")
print("calls:", calls)
Run it with
python main.pyOutput
[2] debug is on
101 102 1
[3] len is still the built-in: 3
calls: 3- The first log call is counted but prints nothing, so the first line shown is [2].
- log reads DEBUG without declaring it: reading a global needs no global statement.
- tickets and orders each have their own count, because every call of make_counter makes a new one.
- Callable[[], int] is the type of a function that takes no arguments and returns an int.
Change it and run it
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
Exercises
Exercise 1 of 2
A page-view counter
record(page) should add one to the module-level views, append the page to history and return the new views. reset() should set views back to 0 and empty history. Both functions are broken: record raises UnboundLocalError, and reset leaves views unchanged. Fix them with the right declaration, and keep history.append and history.clear as they are.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
Hints
Hint 1
Both functions assign to views, so Python treats views as local in each of them.
Hint 2
Add global views as the first line of record and of reset.
Hint 3
history needs no declaration: append and clear change the list without rebinding the name.
Show a solution
One way to solve it. Yours can look different and still pass the checks.
views = 0
history: list[str] = []
def record(page: str) -> int:
"""Count one view of page and return the total so far."""
global views
views += 1
history.append(page)
return views
def reset() -> None:
"""Set the count back to 0 and forget the history."""
global views
views = 0
history.clear()
Run it on your computer
Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.py
views = 0
history: list[str] = []
def record(page: str) -> int:
"""Count one view of page and return the total so far."""
views += 1
history.append(page)
return views
def reset() -> None:
"""Set the count back to 0 and forget the history."""
views = 0
history.clear()
test_main.py
import main
from main import record, reset
def test_record_counts():
"""record returns 1, then 2, after a reset"""
reset()
got = record("/home"), record("/about")
assert got == (1, 2), f"record returned {got!r}, expected (1, 2)"
def test_module_count():
"""The module-level views changes too"""
reset()
record("/home")
assert main.views == 1, f"main.views is {main.views!r} after one record, expected 1"
def test_history():
"""record keeps the pages in order"""
reset()
record("/a")
record("/b")
assert main.history == ["/a", "/b"], f"main.history is {main.history!r}, expected ['/a', '/b']"
def test_reset():
"""reset sets views back to 0 and empties history"""
reset()
record("/a")
reset()
assert main.views == 0, f"main.views is {main.views!r} after reset, expected 0"
assert main.history == [], f"main.history is {main.history!r} after reset, expected []"
On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.
Run the program:
python main.pyRun the checks (needs learnrun.py in the same folder):
python learnrun.py testDownload learnrun.pyExercise 2 of 2
A rate limiter
make_limiter(limit) returns a function allow(). allow() returns True for the first limit calls and False for every call after that. Each limiter keeps its own count: two limiters must not share one. Keep the count in make_limiter and change it from allow with nonlocal. No global variables.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
Hints
Hint 1
allow has to change used, which belongs to make_limiter, the enclosing function.
Hint 2
Start allow with nonlocal used. Then compare used with limit.
Hint 3
if used >= limit: return False. Otherwise add one to used and return True.
Show a solution
One way to solve it. Yours can look different and still pass the checks.
from collections.abc import Callable
def make_limiter(limit: int) -> Callable[[], bool]:
used = 0
def allow() -> bool:
nonlocal used
if used >= limit:
return False
used += 1
return True
return allow
Run it on your computer
Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.py
from collections.abc import Callable
def make_limiter(limit: int) -> Callable[[], bool]:
used = 0
def allow() -> bool:
# Return True for the first `limit` calls, False after that.
return True
return allow
test_main.py
from main import make_limiter
def test_first_calls_allowed():
"""The first three calls of a limit-3 limiter are allowed"""
allow = make_limiter(3)
got = [allow() for _ in range(3)]
assert got == [True, True, True], f"the first three calls returned {got!r}"
def test_then_refused():
"""Calls after the limit are refused"""
allow = make_limiter(2)
got = [allow() for _ in range(4)]
assert got == [True, True, False, False], f"four calls with limit 2 returned {got!r}"
def test_separate_counts():
"""Two limiters keep separate counts"""
a = make_limiter(1)
b = make_limiter(1)
got = a(), a(), b()
assert got == (True, False, True), f"a(), a(), b() returned {got!r}, expected (True, False, True)"
def test_zero_limit():
"""A limit of 0 refuses the first call"""
allow = make_limiter(0)
got = allow()
assert got is False, f"with limit 0 the first call returned {got!r}"
On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.
Run the program:
python main.pyRun the checks (needs learnrun.py in the same folder):
python learnrun.py testDownload learnrun.pyCommon mistakes
Changing a global without declaring it
count = 0
def increment():
count += 1
increment()
What Python prints
UnboundLocalError: cannot access local variable 'count' where it is not associated with a valueWhy, and the fix
count += 1 is an assignment, so count is local in increment, and the local has no value to add to. If you really mean the module-level count, write global count as the first line of the function. Often better: pass the value in and return the new one, count = increment(count).
nonlocal for a module-level name
total = 0
def add(n):
nonlocal total
total += n
What Python prints
SyntaxError: no binding for nonlocal 'total' foundWhy, and the fix
nonlocal only looks in enclosing functions, and add is not inside another function: total is a global. Python rejects the file before running anything. Use global total for a module-level name, and keep nonlocal for closures.
Hiding a built-in with your own name
list = [3, 1, 2]
letters = list("abc")
What Python prints
TypeError: 'list' object is not callableWhy, and the fix
The global name list now refers to your list, and the global scope is searched before the built-ins, so list("abc") tries to call a list. Pick another name, such as numbers. The same happens with str, dict, sum, max and id. In an interactive session, del list removes your global and brings the built-in back into view.
Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source
Exit ticket
5 questions, no hints. Score 80% or more to complete the lesson.
Finish every activity above to unlock the exit ticket.