FreeCampus Python

Unit Challenge: Unlock the Moonlit Library

Traverse a nested magical archive with focused functions, a recursive generator, callback-selected clues, and an audited public unlock operation.
python-foundations functions-call-behavior unit-challenge
Open in Colab
  • Level: Python Foundations · Unit 5 challenge
  • Estimated time: 60–120 minutes
  • Outcome assessed: Compose clear interfaces, recursive lazy traversal, callback policy, and a metadata-preserving decorator into one reusable archive operation.
  • Evidence: Pass 24 progressive assertions, preserve a debugging record, and explain the derived library message.

1. Open the archive without hard-coding its answer

At moonrise, the Moonlit Library rearranges its shelves. Nine readable scrolls contain numbered letters. Sealed scrolls are decoys, decorative scrolls have no clue, and shelves may contain smaller shelves. Your archive engine must discover the message from the data and return the final artifact:

MOONLIGHT OPENS THE ARCHIVE

You will build a small set of cohesive functions rather than one long solution:

  1. normalize scroll titles through a keyword-only option;
  2. traverse shelves with a recursive generator;
  3. select scrolls through a predicate callback;
  4. decode ordered clues into one returned word;
  5. compose those steps in one public unlock operation; and
  6. audit that public call with a decorator that preserves its contract.
NoteKeep the solution closed for the first attempt

Plan for one or two focused hours. Run one assertion group at a time. Open a hint only after recording the exact function, input, actual result, and expected result that currently disagree.

2. Read the archive shape and constraints

A node has one of two forms:

  • a shelf has kind, name, and entries, where every entry is another shelf or scroll;
  • a scroll has kind, title, sealed, and clue, where a clue is either (position, letter) or None.

The traversal order is depth first and left to right. When the current node is a scroll, yield it. When it is a shelf, recurse into each entry in order.

Constraints

  1. Preserve every supplied public name, signature, and docstring.
  2. Derive the artifact from the archive; do not assign "MOONLIGHT" or the solved sentence directly in a function.
  3. Do not mutate the archive or its scroll records.
  4. Keep traversal lazy: iter_scrolls and select_scrolls must yield values, not build hidden result lists.
  5. Pass a predicate function into select_scrolls; do not hard-code the readable-scroll policy inside traversal.
  6. Keep calculation functions free of printing.
  7. The decorator must forward arguments, return the original result, preserve metadata with wraps, and append one audit event after a successful call.
  8. Use only the Python standard library; classes, files, and exception handlers are outside this challenge.

3. Start from the contract

Run the data cell unchanged:

archive = {
    "kind": "shelf",
    "name": "Atrium",
    "entries": [
        {
            "kind": "scroll",
            "title": " moon map ",
            "sealed": False,
            "clue": (0, "M"),
        },
        {
            "kind": "shelf",
            "name": "East Gallery",
            "entries": [
                {
                    "kind": "scroll",
                    "title": "Owl Song",
                    "sealed": False,
                    "clue": (1, "O"),
                },
                {
                    "kind": "shelf",
                    "name": "Upper Nook",
                    "entries": [
                        {
                            "kind": "scroll",
                            "title": "Oracle Note",
                            "sealed": False,
                            "clue": (2, "O"),
                        },
                        {
                            "kind": "scroll",
                            "title": "Counterfeit Omen",
                            "sealed": True,
                            "clue": (2, "X"),
                        },
                        {
                            "kind": "scroll",
                            "title": "North Bell",
                            "sealed": False,
                            "clue": (3, "N"),
                        },
                    ],
                },
            ],
        },
        {
            "kind": "shelf",
            "name": "Lantern Garden",
            "entries": [
                {
                    "kind": "scroll",
                    "title": "Lantern Leaf",
                    "sealed": False,
                    "clue": (4, "L"),
                },
                {
                    "kind": "scroll",
                    "title": "Idle Decoration",
                    "sealed": False,
                    "clue": None,
                },
                {
                    "kind": "shelf",
                    "name": "Glass Cabinet",
                    "entries": [
                        {
                            "kind": "scroll",
                            "title": "Ink Index",
                            "sealed": False,
                            "clue": (5, "I"),
                        },
                        {
                            "kind": "scroll",
                            "title": "Glass Glyph",
                            "sealed": False,
                            "clue": (6, "G"),
                        },
                        {
                            "kind": "scroll",
                            "title": "Horizon Hymn",
                            "sealed": False,
                            "clue": (7, "H"),
                        },
                    ],
                },
            ],
        },
        {
            "kind": "scroll",
            "title": "Twilight Key",
            "sealed": False,
            "clue": (8, "T"),
        },
        {
            "kind": "scroll",
            "title": "Sealed Mirror",
            "sealed": True,
            "clue": None,
        },
    ],
}

Then copy the scaffold. Implement the functions in this order; do not change the public call shapes.

from functools import wraps


def normalize_title(title, *, separator="-"):
    """Return a case-folded title with words joined by separator."""
    raise NotImplementedError


def iter_scrolls(node):
    """Yield scroll nodes depth first and left to right."""
    raise NotImplementedError


def is_readable(scroll):
    """Return whether a scroll is unsealed and contains a clue."""
    raise NotImplementedError


def select_scrolls(scrolls, predicate):
    """Yield scrolls for which predicate(scroll) is true."""
    raise NotImplementedError


def decode_clues(scrolls):
    """Return clue letters ordered by numeric clue position."""
    raise NotImplementedError


def audit_calls(log):
    """Return a decorator that appends one event after each successful call."""
    raise NotImplementedError


audit_log = []


@audit_calls(audit_log)
def unlock_archive(archive, *, predicate=is_readable):
    """Return the decoded word followed by the archive-opening phrase."""
    raise NotImplementedError

An audit event has exactly these fields:

{
    "function": "unlock_archive",
    "args_count": 1,
    "keyword_names": [],
    "result": "MOONLIGHT OPENS THE ARCHIVE",
}

keyword_names is the sorted list of keyword names received by the wrapper.

4. Build the engine in observable stages

Use this order so one missing contract does not hide another:

  1. Normalize ordinary, irregular-space, and empty titles. An empty title becomes "untitled"; do not use the separator as a default title.
  2. Implement the generator’s scroll base case. Then delegate shelf entries with a recursive call.
  3. Create a fresh generator and prove partial consumption before converting any complete traversal to a list.
  4. Implement the readable predicate and selector. Keep predicate(scroll) in the selector, not inside the traversal function.
  5. Extract clue tuples, sort them by numeric position, and join uppercase letters. An empty input returns the empty string.
  6. Compose a fresh traversal and selection in unlock_archive. Reusing the exhausted probe will lose scrolls.
  7. Implement the three decorator layers: audit_calls(log), decorate(function), and wrapper(*args, **kwargs).
  8. Call the decorated public operation only after all smaller assertions pass.

Write a short trace for the recursive route from Atrium to Oracle Note. Mark the shelf frame that waits, the smaller entry passed onward, and the scroll that is yielded.

5. Run progressive assertions

The 24 numbered assertions are the acceptance contract. Run each group after implementing its named behavior; do not weaken an expected value.

Title and interface evidence

assert callable(normalize_title)                                      # 1
assert normalize_title.__doc__.startswith("Return a case-folded")     # 2
assert normalize_title("  Moon   Map  ") == "moon-map"                # 3
assert normalize_title("Moon Map", separator="_") == "moon_map"      # 4
assert normalize_title("   ") == "untitled"                          # 5

Recursive traversal and source evidence

import copy

before = copy.deepcopy(archive)
all_scrolls = list(iter_scrolls(archive))

assert len(all_scrolls) == 12                                         # 6
assert all_scrolls[0]["title"] == " moon map "                        # 7
assert all_scrolls[-1]["title"] == "Sealed Mirror"                   # 8
assert archive == before                                              # 9

Partial consumption and clean exhaustion

probe = iter_scrolls(archive)
assert next(probe)["title"] == " moon map "                           # 10
assert next(probe)["title"] == "Owl Song"                            # 11
assert len(list(probe)) == 10                                         # 12
assert next(probe, "exhausted") == "exhausted"                       # 13

Callback and decoding evidence

readable = list(select_scrolls(iter_scrolls(archive), is_readable))

assert len(readable) == 9                                             # 14
assert all(not scroll["sealed"] and scroll["clue"] for scroll in readable)  # 15
assert decode_clues(readable) == "MOONLIGHT"                          # 16
assert decode_clues([]) == ""                                        # 17

Decorated composition evidence

assert unlock_archive.__name__ == "unlock_archive"                    # 18
assert unlock_archive.__doc__.startswith("Return the decoded word")   # 19

artifact = unlock_archive(archive)
assert artifact == "MOONLIGHT OPENS THE ARCHIVE"                      # 20
assert len(audit_log) == 1                                            # 21
assert audit_log[0] == {                                              # 22
    "function": "unlock_archive",
    "args_count": 1,
    "keyword_names": [],
    "result": "MOONLIGHT OPENS THE ARCHIVE",
}

Controlled variation evidence

Seal the first real clue and add an empty shelf. The decoder now returns the remaining ordered letters, and the recursive generator must cross the empty shelf without yielding a value.

changed_archive = copy.deepcopy(archive)
changed_archive["entries"][0]["sealed"] = True
changed_archive["entries"].append(
    {"kind": "shelf", "name": "Silent Annex", "entries": []}
)

changed_artifact = unlock_archive(changed_archive)
assert changed_artifact == "OONLIGHT OPENS THE ARCHIVE"               # 23
assert len(audit_log) == 2 and audit_log[-1]["result"] == changed_artifact  # 24
WarningDo not turn an exhausted generator into a decoding bug

If a complete result is missing its first letters, check whether exploratory next(...) calls consumed the same generator. Create a fresh generator for each independent complete traversal.

6. Use the hint ladder only when needed

Hint 1: locate the contract that currently disagrees

Write down the function name, input shape, expected returned value, and intended side effect. A scroll is the generator’s base case. A shelf owns recursive progress through its smaller entries. Title normalization can split on whitespace and join the words without changing the source string.

Hint 2: follow the boundary between behaviors

iter_scrolls knows nothing about sealed clues. select_scrolls calls the supplied predicate for every yielded scroll. decode_clues receives only the selected scrolls, extracts their clue tuples, sorts by position, and joins the letters. Use a fresh traversal when composing those stages.

Hint 3: assemble the decorator and public pipeline

Pseudocode for the public operation:

scroll stream = recursively traverse archive
readable stream = select from scroll stream with predicate
word = decode readable stream
return word + opening phrase

The decorator factory returns decorate; decorate returns a @wraps(function) wrapper; the wrapper calls with *args, **kwargs, stores the result, appends the specified event, and returns the unchanged result.

7. Keep debugging evidence

Preserve one failed assertion from before your repair. Do not record only “it did not work.” Connect the evidence to one call boundary or state transition.

Failure Arguments and actual result Expected contract Single hypothesis Controlled change and rerun
Which numbered assertion failed? What exact values or error appeared? What should this function yield, return, preserve, or append? Which base case, callback, generator state, or wrapper step could explain it? What one edit did you make, and which earlier checks still pass?

A useful record might reveal that a probe had already consumed two scrolls, that the predicate was called too early, or that the wrapper forgot to return the original result. Keep the record even after the complete suite passes.

8. Compare with a complete solution

Open this only after making a serious attempt and using the hints in order.

Show one complete Moonlit Library solution
from functools import wraps


def normalize_title(title, *, separator="-"):
    """Return a case-folded title with words joined by separator."""
    words = title.casefold().split()
    if not words:
        return "untitled"
    return separator.join(words)


def iter_scrolls(node):
    """Yield scroll nodes depth first and left to right."""
    if node["kind"] == "scroll":
        yield node
        return

    for entry in node["entries"]:
        yield from iter_scrolls(entry)


def is_readable(scroll):
    """Return whether a scroll is unsealed and contains a clue."""
    return not scroll["sealed"] and scroll["clue"] is not None


def select_scrolls(scrolls, predicate):
    """Yield scrolls for which predicate(scroll) is true."""
    for scroll in scrolls:
        if predicate(scroll):
            yield scroll


def decode_clues(scrolls):
    """Return clue letters ordered by numeric clue position."""
    clues = []
    for scroll in scrolls:
        clues.append(scroll["clue"])
    clues.sort(key=lambda clue: clue[0])
    return "".join(letter.upper() for _, letter in clues)


def audit_calls(log):
    """Return a decorator that appends one event after each successful call."""
    def decorate(function):
        @wraps(function)
        def wrapper(*args, **kwargs):
            result = function(*args, **kwargs)
            event = {
                "function": function.__name__,
                "args_count": len(args),
                "keyword_names": sorted(kwargs),
                "result": result,
            }
            log.append(event)
            return result

        return wrapper

    return decorate


audit_log = []


@audit_calls(audit_log)
def unlock_archive(archive, *, predicate=is_readable):
    """Return the decoded word followed by the archive-opening phrase."""
    scrolls = iter_scrolls(archive)
    selected = select_scrolls(scrolls, predicate)
    word = decode_clues(selected)
    return f"{word} OPENS THE ARCHIVE"

9. Predict an alternate library policy

The supplied is_readable rejects sealed clues. Try an alternate predicate that accepts every scroll containing a clue:

def has_clue(scroll):
    return scroll["clue"] is not None

Before calling unlock_archive(archive, predicate=has_clue), predict:

  • whether the sealed Counterfeit Omen enters the selected stream;
  • where its (2, "X") clue appears after stable sorting;
  • the exact decoded word;
  • the new keyword_names audit value; and
  • whether the original archive changes.

The extra clue should make the danger of changing policy visible, not merely rename the same result. Explain why traversal required no edits.

10. Check your understanding

Answer from the completed artifact and its traces rather than from the story alone.

11. Decide whether the challenge is complete

Evidence rubric

Evidence Ready to record when
Function contracts Every supplied name, signature, docstring, result, and deliberate side effect matches its promise.
Recursive and lazy behavior You can trace a nested shelf, partial consumption, resume, and exhaustion.
Callback policy You can explain why changing the predicate changes selection without changing traversal.
Decorated behavior Arguments, result, metadata, and one audit event survive the wrapper.
Debugging One record connects a failed assertion to a specific state or boundary and verified rerun.
Reproducibility All 24 assertions pass from a clean notebook state with the solution section closed.

Record completion only when every statement is true:

This button stores a self-reported marker only in this browser. It does not submit the artifact, grade it, verify identity, or issue a certificate.

Not yet recorded.

Key points

  • Focused contracts make a large challenge solvable one returned or yielded value at a time.
  • Recursive traversal follows nested data; generators expose that traversal incrementally.
  • Callback policy belongs outside the traversal, so the same mechanism supports controlled selection changes.
  • Fresh generator objects prevent exploratory consumption from contaminating a complete result.
  • A transparent audit decorator forwards arguments, preserves metadata, records one event, and returns the original artifact.
  • The successful sentence is evidence produced by composition, not a value hidden directly in the implementation.
Back to top