diff --git a/exercises/foundations/06_scopes/README.md b/exercises/foundations/06_scopes/README.md new file mode 100644 index 0000000..0af7208 --- /dev/null +++ b/exercises/foundations/06_scopes/README.md @@ -0,0 +1,96 @@ +# Exercise 06 - Scope Station: Reactor Rescue (Scope and Namespaces) + +## Description +Orbital Station Alpha is failing! Engineers rushed the code and left variables in the wrong places: some are trapped inside functions, some are missing the right keyword, and some are hiding Python's own tools. + +This is a **debugging game**. `scopes.py` is already written, but it is *almost* working. Your job in each level is to figure out **where each variable must live** (and which keyword, if any, it needs) so the system runs. + +Remember the question from the lesson: *"Where does this name live, and who needs to see it?"* + +> Do not rename the functions. The tests call them by name. You decide where the variables go. + +--- + +## Levels + +### Level 1 - Power Grid (Local vs. Global) +`show_banner()` crashes with a `NameError`. The station name is created inside `boot_system()`, but two functions need it. +* **Your job**: make both `boot_system()` and `show_banner()` work. +* **Expected**: `show_banner()` returns `"=== Orbital Station Alpha ==="`. + +> **Next step:** Run `python check.py 06`. Once Level 1 passes, remove the `@pytest.mark.skip` line above `test_level2` in `test_scopes.py`. + +### Level 2 - Life Support (Enclosing Scope) +`build_oxygen_monitor()` returns an inner function `read_level()`, but it can't find the oxygen value. +* **Your job**: put `oxygen` where `read_level()` can find it by looking outward, and don't use a global. +* **Expected**: the monitor returns `"Oxygen at 100%"`. + +> **Next step:** Unlock `test_level3`. + +### Level 3 - Comms Array (Built-in Scope) +`summarize_signals()` fails with `TypeError: ... object is not callable`. Something is hiding Python's built-in tools. +* **Your job**: find what is shadowing the built-ins and fix it. +* **Expected**: `"3 signals, strength 49"`. + +> **Next step:** Unlock `test_level4`. + +### Level 4 - Reactor Core (`UnboundLocalError`) +`add_bonus_power()` crashes with `UnboundLocalError`. +* **Your job**: fix it by sending the value **in** with a parameter and sending the result **out** with `return`. The global `power` must stay `50`, and `global` is not allowed in this level. +* **Expected**: `add_bonus_power(50)` returns `60`. + +> **Next step:** Unlock `test_level5`. + +### Level 5 - Cargo Bay (Mutable Objects) +`load_crate()` adds items to a list that vanishes when the function ends, and `get_manifest()` can't find it. +* **Your job**: make both functions share the same list. You don't need `global`, because you only change the list's contents. +* **Expected**: loading `"Titanium"` then `"Ice"` gives a manifest ending in `["Titanium", "Ice"]`. + +> **Next step:** Unlock `test_level6`. + +### Level 6 - Alarm System (`global`) +`trigger_alarm()` should count alarms in the global `alarm_count`, but crashes. +* **Your job**: let the function rebind the global variable. +* **Expected**: three calls return `1`, `2`, `3` (from a fresh start) and `alarm_count` ends up updated. + +> **Next step:** Unlock `test_level7`. + +### Level 7 - Drone Memory (`nonlocal`) +Each drone made by `make_drone_counter()` should remember how many scans it did, but `scan()` crashes. +* **Your job**: let the inner function update the enclosing variable. +* **Expected**: one drone counts `1, 2, 3`, and a second drone starts again at `1`. + +> **Next step:** Unlock `test_level8`. + +### Level 8 - Diagnostics (`globals()` and `locals()`) +Complete the three helper functions in the `LEVEL 8` section: +* `list_local_names(a, b)`: return a **sorted list** of the names in the function's local namespace. +* `is_global(name)`: return `True` if `name` exists in the global namespace. +* `probe_hidden()`: try to read `secret_code` (it only exists inside `_make_secret()`). Return `True` if Python raises `NameError`. + +> **Next step:** Unlock `test_level9`. + +### Level 9 - BOSS: Reactor Boot Sequence +`run_station()` has **three** different scope bugs at once. Use everything you have learned. +* **Expected**: `"Checks: 3 | Passed: 2 | Peak: 91"`. + +> **Next step:** If Level 9 passes, congratulations! You saved the station. + +--- + +## How to Run the Tests +Run the following command from the repository root: +```bash +python check.py 06 +``` + +Or run directly with pytest: +```bash +pytest exercises/foundations/06_scopes/test_scopes.py -v +``` + +--- + +## Resources & Notes +Review the concepts covered in this exercise in: +* [The Python Ledger Curriculum: Scope and Namespaces](https://thepythonledger.github.io/Docusaurus-engine/lessons/code-organization/scope-and-namespaces) diff --git a/exercises/foundations/06_scopes/scopes.py b/exercises/foundations/06_scopes/scopes.py new file mode 100644 index 0000000..29bb38b --- /dev/null +++ b/exercises/foundations/06_scopes/scopes.py @@ -0,0 +1,121 @@ +# ORBITAL STATION ALPHA - Reactor Rescue +# Every system below is almost working. Find WHERE each variable should live. +# Do not rename the functions - the tests call them by name. + + +# LEVEL 1 - Power Grid +def boot_system(): + STATION_NAME = "Orbital Station Alpha" + return f"Booting {STATION_NAME}..." + + +def show_banner(): + return f"=== {STATION_NAME} ===" + + +# LEVEL 2 - Life Support +def calibrate_sensors(): + oxygen = 100 + return "Sensors calibrated" + + +def build_oxygen_monitor(): + def read_level(): + return f"Oxygen at {oxygen}%" + + return read_level + + +# LEVEL 3 - Comms Array +list = [12, 7, 30] # incoming signal strengths +sum = 0 # total strength + + +def summarize_signals(): + return f"{len(list)} signals, strength {sum(list)}" + + +# LEVEL 4 - Reactor Core +power = 50 + + +def add_bonus_power(): + print("Current power:", power) + power = power + 10 + return power + + +# LEVEL 5 - Cargo Bay +def load_crate(item): + cargo = [] + cargo.append(item) + + +def get_manifest(): + return cargo + + +# LEVEL 6 - Alarm System +alarm_count = 0 + + +def trigger_alarm(): + alarm_count = alarm_count + 1 + return alarm_count + + +# LEVEL 7 - Drone Memory +def make_drone_counter(): + scans = 0 + + def scan(): + scans = scans + 1 + return scans + + return scan + + +# LEVEL 8 - Diagnostics +def _make_secret(): + secret_code = 42 + return "secret stored" + + +def list_local_names(a, b): + c = a + b + # Return a sorted list of the names in THIS function's local namespace. + pass + + +def is_global(name): + # Return True if `name` exists in the global namespace. + pass + + +def probe_hidden(): + _make_secret() + # Try to read `secret_code` here. Return True if Python raises NameError + # (it is hidden inside _make_secret), otherwise return False. + pass + + +# LEVEL 9 - BOSS: Reactor Boot Sequence +def configure_reactor(): + REACTOR_TARGET = 80 + return "Reactor configured" + + +readings = [72, 91, 85] +max = 0 # highest reading seen so far + + +def run_station(): + checks = 0 + + def run_check(reading): + checks = checks + 1 + return reading >= REACTOR_TARGET + + results = [run_check(r) for r in readings] + highest = max(readings) + return f"Checks: {checks} | Passed: {results.count(True)} | Peak: {highest}" diff --git a/exercises/foundations/06_scopes/solution/scopes_solution.py b/exercises/foundations/06_scopes/solution/scopes_solution.py new file mode 100644 index 0000000..9e0c221 --- /dev/null +++ b/exercises/foundations/06_scopes/solution/scopes_solution.py @@ -0,0 +1,123 @@ +# ORBITAL STATION ALPHA - Reactor Rescue (reference solution) + +# LEVEL 1 - Power Grid: shared by two functions -> module level +STATION_NAME = "Orbital Station Alpha" + + +def boot_system(): + return f"Booting {STATION_NAME}..." + + +def show_banner(): + return f"=== {STATION_NAME} ===" + + +# LEVEL 2 - Life Support: lives in the enclosing function +def calibrate_sensors(): + return "Sensors calibrated" + + +def build_oxygen_monitor(): + oxygen = 100 + + def read_level(): + return f"Oxygen at {oxygen}%" + + return read_level + + +# LEVEL 3 - Comms Array: don't shadow built-ins +signals = [12, 7, 30] + + +def summarize_signals(): + return f"{len(signals)} signals, strength {sum(signals)}" + + +# LEVEL 4 - Reactor Core: parameter + return instead of global +power = 50 + + +def add_bonus_power(current_power): + print("Current power:", current_power) + return current_power + 10 + + +# LEVEL 5 - Cargo Bay: shared mutable list, no `global` needed +cargo = [] + + +def load_crate(item): + cargo.append(item) + + +def get_manifest(): + return cargo + + +# LEVEL 6 - Alarm System: rebinding a global needs `global` +alarm_count = 0 + + +def trigger_alarm(): + global alarm_count + alarm_count = alarm_count + 1 + return alarm_count + + +# LEVEL 7 - Drone Memory: rebinding an enclosing variable needs `nonlocal` +def make_drone_counter(): + scans = 0 + + def scan(): + nonlocal scans + scans = scans + 1 + return scans + + return scan + + +# LEVEL 8 - Diagnostics +def _make_secret(): + secret_code = 42 + return "secret stored" + + +def list_local_names(a, b): + c = a + b + return sorted(locals().keys()) + + +def is_global(name): + return name in globals() + + +def probe_hidden(): + _make_secret() + try: + secret_code + except NameError: + return True + return False + + +# LEVEL 9 - BOSS: Reactor Boot Sequence +REACTOR_TARGET = 80 +readings = [72, 91, 85] + + +def configure_reactor(): + return "Reactor configured" + + +def run_station(): + checks = 0 + + def run_check(reading): + nonlocal checks + checks = checks + 1 + return reading >= REACTOR_TARGET + + results = [run_check(r) for r in readings] + highest = max(readings) + return f"Checks: {checks} | Passed: {results.count(True)} | Peak: {highest}" diff --git a/exercises/foundations/06_scopes/solution/test_scopes_solution.py b/exercises/foundations/06_scopes/solution/test_scopes_solution.py new file mode 100644 index 0000000..196bbf0 --- /dev/null +++ b/exercises/foundations/06_scopes/solution/test_scopes_solution.py @@ -0,0 +1,99 @@ +import ast +import inspect +import textwrap + +import pytest + +import scopes_solution as m + + +def _run(func, *args, hint=""): + + try: + return func(*args) + except (NameError, TypeError) as err: + pytest.fail( + f"{func.__name__}() crashed with {type(err).__name__}: {err}\n" + f"Hint: {hint}", + pytrace=False, + ) + + +def _has_statement(func, node_type): + tree = ast.parse(textwrap.dedent(inspect.getsource(func))) + return any(isinstance(node, node_type) for node in ast.walk(tree)) + + +def test_level1(): + assert "STATION_NAME" in vars(m), ( + "STATION_NAME should be defined at module level so every function can see it." + ) + assert _run(m.boot_system, hint="Who needs the station name?") == ( + "Booting Orbital Station Alpha..." + ) + assert _run(m.show_banner, hint="Where does STATION_NAME have to live?") == ( + "=== Orbital Station Alpha ===" + ) + + +def test_level2(): + monitor = _run(m.build_oxygen_monitor, hint="Which function does read_level() sit in?") + assert _run(monitor, hint="read_level() looks outward: Local, then...?") == ( + "Oxygen at 100%" + ) + assert "oxygen" not in vars(m), "Don't solve this with a module-level variable." + + +def test_level3(): + assert "list" not in vars(m), "You have shadowed the built-in `list`." + assert "sum" not in vars(m), "You have shadowed the built-in `sum`." + assert _run(m.summarize_signals, hint="Built-in names must stay available.") == ( + "3 signals, strength 49" + ) + + +def test_level4(): + assert _run(m.add_bonus_power, 50, hint="Pass the value in instead.") == 60 + assert m.power == 50, "The global `power` must not be changed." + assert not _has_statement(m.add_bonus_power, ast.Global), ( + "Fix this one with a parameter and `return`, not `global`." + ) + + +def test_level5(): + before = len(_run(m.get_manifest, hint="Where must the cargo list live?")) + _run(m.load_crate, "Titanium", hint="Both functions share one list.") + _run(m.load_crate, "Ice", hint="Both functions share one list.") + assert m.get_manifest()[before:] == ["Titanium", "Ice"] + assert not _has_statement(m.load_crate, ast.Global), ( + "Mutating a list in place doesn't need `global`." + ) + + +def test_level6(): + start = m.alarm_count + for expected in (start + 1, start + 2, start + 3): + assert _run(m.trigger_alarm, hint="Rebinding a global needs a keyword.") == expected + assert m.alarm_count == start + 3, "The global alarm_count should be updated." + + +def test_level7(): + drone_a = m.make_drone_counter() + drone_b = m.make_drone_counter() + assert [_run(drone_a, hint="Rebinding an enclosing variable needs a keyword.") + for _ in range(3)] == [1, 2, 3] + assert drone_b() == 1, "Each drone should keep its own count." + + +def test_level8(): + assert m.list_local_names(1, 2) == ["a", "b", "c"] + assert m.is_global("show_banner") is True + assert m.is_global("no_such_name_xyz") is False + assert m.probe_hidden() is True + + +def test_level9(): + assert "max" not in vars(m), "You have shadowed the built-in `max`." + assert _run(m.run_station, hint="Three scope bugs are hiding here.") == ( + "Checks: 3 | Passed: 2 | Peak: 91" + ) diff --git a/exercises/foundations/06_scopes/test_scopes.py b/exercises/foundations/06_scopes/test_scopes.py new file mode 100644 index 0000000..553b3ea --- /dev/null +++ b/exercises/foundations/06_scopes/test_scopes.py @@ -0,0 +1,107 @@ +import ast +import inspect +import textwrap + +import pytest + +import scopes as m + + +def _run(func, *args, hint=""): + + try: + return func(*args) + except (NameError, TypeError) as err: + pytest.fail( + f"{func.__name__}() crashed with {type(err).__name__}: {err}\n" + f"Hint: {hint}", + pytrace=False, + ) + + +def _has_statement(func, node_type): + tree = ast.parse(textwrap.dedent(inspect.getsource(func))) + return any(isinstance(node, node_type) for node in ast.walk(tree)) + + +def test_level1(): + assert "STATION_NAME" in vars(m), ( + "STATION_NAME should be defined at module level so every function can see it." + ) + assert _run(m.boot_system, hint="Who needs the station name?") == ( + "Booting Orbital Station Alpha..." + ) + assert _run(m.show_banner, hint="Where does STATION_NAME have to live?") == ( + "=== Orbital Station Alpha ===" + ) + + +@pytest.mark.skip(reason="Finish level 1 first") +def test_level2(): + monitor = _run(m.build_oxygen_monitor, hint="Which function does read_level() sit in?") + assert _run(monitor, hint="read_level() looks outward: Local, then...?") == ( + "Oxygen at 100%" + ) + assert "oxygen" not in vars(m), "Don't solve this with a module-level variable." + + +@pytest.mark.skip(reason="Finish level 2 first") +def test_level3(): + assert "list" not in vars(m), "You have shadowed the built-in `list`." + assert "sum" not in vars(m), "You have shadowed the built-in `sum`." + assert _run(m.summarize_signals, hint="Built-in names must stay available.") == ( + "3 signals, strength 49" + ) + + +@pytest.mark.skip(reason="Finish level 3 first") +def test_level4(): + assert _run(m.add_bonus_power, 50, hint="Pass the value in instead.") == 60 + assert m.power == 50, "The global `power` must not be changed." + assert not _has_statement(m.add_bonus_power, ast.Global), ( + "Fix this one with a parameter and `return`, not `global`." + ) + + +@pytest.mark.skip(reason="Finish level 4 first") +def test_level5(): + before = len(_run(m.get_manifest, hint="Where must the cargo list live?")) + _run(m.load_crate, "Titanium", hint="Both functions share one list.") + _run(m.load_crate, "Ice", hint="Both functions share one list.") + assert m.get_manifest()[before:] == ["Titanium", "Ice"] + assert not _has_statement(m.load_crate, ast.Global), ( + "Mutating a list in place doesn't need `global`." + ) + + +@pytest.mark.skip(reason="Finish level 5 first") +def test_level6(): + start = m.alarm_count + for expected in (start + 1, start + 2, start + 3): + assert _run(m.trigger_alarm, hint="Rebinding a global needs a keyword.") == expected + assert m.alarm_count == start + 3, "The global alarm_count should be updated." + + +@pytest.mark.skip(reason="Finish level 6 first") +def test_level7(): + drone_a = m.make_drone_counter() + drone_b = m.make_drone_counter() + assert [_run(drone_a, hint="Rebinding an enclosing variable needs a keyword.") + for _ in range(3)] == [1, 2, 3] + assert drone_b() == 1, "Each drone should keep its own count." + + +@pytest.mark.skip(reason="Finish level 7 first") +def test_level8(): + assert m.list_local_names(1, 2) == ["a", "b", "c"] + assert m.is_global("show_banner") is True + assert m.is_global("no_such_name_xyz") is False + assert m.probe_hidden() is True + + +@pytest.mark.skip(reason="Finish level 8 first") +def test_level9(): + assert "max" not in vars(m), "You have shadowed the built-in `max`." + assert _run(m.run_station, hint="Three scope bugs are hiding here.") == ( + "Checks: 3 | Passed: 2 | Peak: 91" + )