{
const scripts = Array.from(
document.querySelectorAll("script.fcpython-ojs-quiz-config")
);
const script = scripts.find(
(node) => node.dataset.fcpythonRendered !== "true"
);
if (!script) {
return html`<div class="fcpython-quiz fcpython-quiz-warning">
Quiz configuration was not found.
</div>`;
}
script.dataset.fcpythonRendered = "true";
const quiz = JSON.parse(script.textContent);
const container = html`<div class="fcpython-quiz"></div>`;
const title = document.createElement("h3");
title.textContent = quiz.title;
container.appendChild(title);
const instructions = document.createElement("p");
instructions.textContent = quiz.instructions;
container.appendChild(instructions);
const progress = document.createElement("div");
progress.className = "fcpython-quiz-progress";
const counter = document.createElement("p");
counter.className = "fcpython-quiz-counter";
counter.setAttribute("aria-live", "polite");
progress.appendChild(counter);
const tabs = document.createElement("div");
tabs.className = "fcpython-quiz-steps";
tabs.setAttribute("role", "tablist");
tabs.setAttribute("aria-label", "Quiz questions");
progress.appendChild(tabs);
container.appendChild(progress);
const questions = document.createElement("div");
questions.className = "fcpython-quiz-questions";
const feedbackNodes = [];
const questionPanels = [];
const stepButtons = [];
let currentQuestion = 0;
function setStepStatus(questionIndex, status) {
const step = stepButtons[questionIndex];
const question = quiz.questions[questionIndex];
const statuses = {
answered: "answered",
correct: "correct",
incorrect: "incorrect",
unanswered: "not answered",
};
step.classList.remove(
"is-answered",
"is-correct",
"is-incorrect",
"is-unanswered"
);
if (status) {
step.classList.add(`is-${status}`);
}
const statusLabel = status ? `, ${statuses[status]}` : "";
step.setAttribute(
"aria-label",
`Question ${questionIndex + 1}: ${question.prompt}${statusLabel}`
);
}
function showQuestion(questionIndex, focusPanel = false) {
currentQuestion = Math.max(
0,
Math.min(questionIndex, quiz.questions.length - 1)
);
questionPanels.forEach((panel, index) => {
panel.hidden = index !== currentQuestion;
});
stepButtons.forEach((step, index) => {
const isCurrent = index === currentQuestion;
step.classList.toggle("is-current", isCurrent);
step.setAttribute("aria-selected", String(isCurrent));
step.tabIndex = isCurrent ? 0 : -1;
});
counter.textContent = `Question ${currentQuestion + 1} of ${quiz.questions.length}`;
const selected = questionPanels[currentQuestion].querySelector(
"input[type='radio']:checked"
);
const isLastQuestion = currentQuestion === quiz.questions.length - 1;
previous.hidden = currentQuestion === 0;
next.hidden = isLastQuestion || !selected;
check.hidden = !isLastQuestion;
if (focusPanel) {
questionPanels[currentQuestion].focus();
}
}
quiz.questions.forEach((question, questionIndex) => {
const tabId = `${quiz.id}-question-tab-${questionIndex + 1}`;
const panelId = `${quiz.id}-question-panel-${questionIndex + 1}`;
const step = document.createElement("button");
step.type = "button";
step.className = "fcpython-quiz-step";
step.id = tabId;
step.textContent = String(questionIndex + 1);
step.setAttribute("role", "tab");
step.setAttribute("aria-controls", panelId);
step.setAttribute("aria-selected", "false");
step.tabIndex = -1;
step.addEventListener("click", () => showQuestion(questionIndex));
step.addEventListener("keydown", (event) => {
let destination = null;
if (event.key === "ArrowRight") {
destination = (questionIndex + 1) % quiz.questions.length;
} else if (event.key === "ArrowLeft") {
destination =
(questionIndex - 1 + quiz.questions.length) % quiz.questions.length;
} else if (event.key === "Home") {
destination = 0;
} else if (event.key === "End") {
destination = quiz.questions.length - 1;
}
if (destination !== null) {
event.preventDefault();
showQuestion(destination);
stepButtons[destination].focus();
}
});
stepButtons.push(step);
tabs.appendChild(step);
setStepStatus(questionIndex, "");
const panel = document.createElement("div");
panel.className = "fcpython-quiz-panel";
panel.id = panelId;
panel.setAttribute("role", "tabpanel");
panel.setAttribute("aria-labelledby", tabId);
panel.tabIndex = -1;
const fieldset = document.createElement("fieldset");
fieldset.className = "fcpython-quiz-question";
const legend = document.createElement("legend");
legend.textContent = question.prompt;
fieldset.appendChild(legend);
question.options.forEach((option, optionIndex) => {
const label = document.createElement("label");
label.className = "fcpython-quiz-option";
const input = document.createElement("input");
input.type = "radio";
input.name = `${quiz.id}-${question.id}`;
input.value = String(optionIndex);
input.addEventListener("change", () => {
setStepStatus(questionIndex, "answered");
feedbackNodes[questionIndex].textContent = "";
feedbackNodes[questionIndex].className = "fcpython-quiz-feedback";
score.textContent = "";
showQuestion(questionIndex);
});
const text = document.createElement("span");
text.textContent = option;
label.appendChild(input);
label.appendChild(text);
fieldset.appendChild(label);
});
const feedback = document.createElement("p");
feedback.className = "fcpython-quiz-feedback";
feedback.setAttribute("aria-live", "polite");
feedbackNodes.push(feedback);
fieldset.appendChild(feedback);
panel.appendChild(fieldset);
questionPanels.push(panel);
questions.appendChild(panel);
});
container.appendChild(questions);
const actions = document.createElement("div");
actions.className = "fcpython-quiz-actions";
const previous = document.createElement("button");
previous.type = "button";
previous.className = "fcpython-quiz-secondary";
previous.textContent = "Previous";
previous.addEventListener("click", () => {
showQuestion(currentQuestion - 1, true);
});
const next = document.createElement("button");
next.type = "button";
next.textContent = "Next question";
next.addEventListener("click", () => {
showQuestion(currentQuestion + 1, true);
});
const check = document.createElement("button");
check.type = "button";
check.textContent = "Check answers";
const reset = document.createElement("button");
reset.type = "button";
reset.className = "fcpython-quiz-secondary fcpython-quiz-reset";
reset.textContent = "Reset";
const score = document.createElement("p");
score.className = "fcpython-quiz-score";
score.setAttribute("aria-live", "polite");
check.addEventListener("click", () => {
let correctCount = 0;
let firstQuestionToReview = null;
quiz.questions.forEach((question, questionIndex) => {
const selected = container.querySelector(
`input[name="${quiz.id}-${question.id}"]:checked`
);
const feedback = feedbackNodes[questionIndex];
if (!selected) {
feedback.textContent = "Choose an answer before checking.";
feedback.className = "fcpython-quiz-feedback";
setStepStatus(questionIndex, "unanswered");
if (firstQuestionToReview === null) {
firstQuestionToReview = questionIndex;
}
return;
}
const selectedIndex = Number(selected.value);
if (selectedIndex === question.answer_index) {
correctCount += 1;
feedback.textContent = `✅ Correct. ${question.explanation}`;
feedback.className = "fcpython-quiz-feedback is-correct";
setStepStatus(questionIndex, "correct");
} else {
const answer = question.options[question.answer_index];
feedback.textContent = `❌ Not yet. Correct answer: ${answer}. ${question.explanation}`;
feedback.className = "fcpython-quiz-feedback is-incorrect";
setStepStatus(questionIndex, "incorrect");
if (firstQuestionToReview === null) {
firstQuestionToReview = questionIndex;
}
}
});
score.textContent = `Score: ${correctCount}/${quiz.questions.length}`;
if (firstQuestionToReview !== null) {
showQuestion(firstQuestionToReview, true);
}
});
reset.addEventListener("click", () => {
container.querySelectorAll("input[type='radio']").forEach((input) => {
input.checked = false;
});
feedbackNodes.forEach((feedback) => {
feedback.textContent = "";
feedback.className = "fcpython-quiz-feedback";
});
stepButtons.forEach((_, questionIndex) => {
setStepStatus(questionIndex, "");
});
score.textContent = "";
showQuestion(0, true);
});
actions.appendChild(previous);
actions.appendChild(next);
actions.appendChild(check);
actions.appendChild(reset);
container.appendChild(actions);
container.appendChild(score);
showQuestion(0);
return container;
}
FreeCampus Python
Unit Challenge: Release the Moonlight Cipher Kit
Repair, package, build, inspect, and clean-install a moonlit cipher puzzle whose public API and installed command reveal the same hidden phrase.
python-foundations
modules-environments-projects
unit-challenge
Course progress
0%
1. Challenge outcome
The Moonlight Museum has recovered four fragments from a lunar vault. The decoder works, but an overexcited apprentice scattered it across a broken Python package. Importing the package leaks the secret, the advertised command points to a missing callable, module mode produces no useful output, the Python version claim excludes every real visitor, and Git is preparing to preserve generated debris.
Repair and release Moonlight Cipher Kit 0.1.0. Both:
must reveal exactly:
An ordinary import mooncipher must print nothing. The phrase must come from the package’s public decoding behavior—not from a second hard-coded print added to each launcher. Finally, build a wheel, inspect it, install that exact file in a fresh environment, and repeat the proof from outside the source repository.
NoteUse evidence, not repeated installation guesses
Run one stage at a time. Repair the earliest failed contract, rerun that stage, then continue. Open a hint only after recording the command, observed output, and one hypothesis.
2. Understand the acceptance example
The museum’s fragments are:
normalize_fragment(value) must:
- treat hyphens and underscores as word separators;
- remove surrounding and repeated whitespace;
- return uppercase words separated by one ordinary space; and
- return
""when no word remains.
decode_fragments(values) normalizes every fragment, discards empty results, and joins the remaining text with one space.
The package root must publicly expose both functions:
The callable main(argv=None) belongs in mooncipher.cli. With no supplied fragments it decodes the museum fixture. With a sequence supplied explicitly, it decodes that sequence. It prints one result and returns None; full CLI parsing belongs to Unit 12.
The release metadata contract is:
| Field | Required value |
|---|---|
| distribution name | moonlight-cipher-kit |
| version | 0.1.0 |
| Python | >=3.10 |
| build backend | hatchling.build |
| runtime dependencies | none |
| console command | mooncipher |
| command target | mooncipher.cli:main |
3. Start from the contract
Run this bootstrap in a disposable directory. It creates every starter file, including deliberate defects. Do not run it inside another project because it creates a new moonlight-cipher-kit folder.
from pathlib import Path
from textwrap import dedent
root = Path("moonlight-cipher-kit")
files = {
".gitignore": "*.pyc\n",
"README.md": dedent(
"""\
# Moonlight Cipher Kit
Decode word fragments recovered from the Moonlight Museum's lunar vault.
"""
),
"pyproject.toml": dedent(
"""\
[build-system]
requires = ["hatchling>=1.26"]
build-backend = "hatchling.build"
[project]
name = "moonlight-cipher-kit"
version = "0.1.0"
description = "Decode fragments from a moonlit puzzle vault."
readme = "README.md"
requires-python = ">=99"
dependencies = []
[project.scripts]
mooncipher = "mooncipher.cli:launch"
[tool.hatch.build.targets.wheel]
packages = ["src/mooncipher"]
"""
),
"src/mooncipher/decoder.py": dedent(
"""\
def normalize_fragment(value):
\"\"\"Return uppercase words separated by one space.\"\"\"
separated = value.replace("-", " ").replace("_", " ")
return " ".join(separated.split()).upper()
def decode_fragments(values):
\"\"\"Normalize fragments and join the non-empty results.\"\"\"
cleaned = [normalize_fragment(value) for value in values]
return " ".join(value for value in cleaned if value)
print(decode_fragments([" open-", "_the_", " lunar ", "-vault "]))
"""
),
"src/mooncipher/__init__.py": dedent(
"""\
\"\"\"Tools for decoding the Moonlight Museum's fragments.\"\"\"
"""
),
"src/mooncipher/cli.py": dedent(
"""\
import sys
from .decoder import decode_fragments
DEFAULT_FRAGMENTS = [" open-", "_the_", " lunar ", "-vault "]
def main(argv=None):
\"\"\"Decode supplied fragments, or the museum fixture, and print it.\"\"\"
fragments = list(sys.argv[1:] if argv is None else argv)
print(decode_fragments(fragments or DEFAULT_FRAGMENTS))
main()
"""
),
"src/mooncipher/__main__.py": dedent(
"""\
from .cli import main
main
"""
),
"checks/check_release.py": dedent(
"""\
import contextlib
import importlib.util
import io
import os
import subprocess
import sys
import tomllib
from email.parser import BytesParser
from email.policy import default
from pathlib import Path
from tempfile import TemporaryDirectory
from zipfile import ZipFile
ROOT = Path(__file__).resolve().parents[1]
SOURCE = ROOT / "src" / "mooncipher"
EXPECTED = "OPEN THE LUNAR VAULT"
checks_run = 0
def check(condition, message):
global checks_run
assert condition, message
checks_run += 1
def environment_python(environment):
relative = Path("Scripts/python.exe") if os.name == "nt" else Path("bin/python")
return environment / relative
def command_path(environment, name):
if os.name == "nt":
return environment / "Scripts" / f"{name}.exe"
return environment / "bin" / name
def run(command, cwd):
return subprocess.run(
[str(part) for part in command],
cwd=cwd,
text=True,
capture_output=True,
)
def source_checks():
required = ["__init__.py", "__main__.py", "cli.py", "decoder.py"]
check(all((SOURCE / name).is_file() for name in required), "source files are missing")
spec = importlib.util.spec_from_file_location("moon_decoder_check", SOURCE / "decoder.py")
check(spec is not None and spec.loader is not None, "decoder cannot be loaded")
module = importlib.util.module_from_spec(spec)
captured = io.StringIO()
with contextlib.redirect_stdout(captured):
spec.loader.exec_module(module)
check(captured.getvalue() == "", "decoder printed during import")
check(callable(module.normalize_fragment), "normalize_fragment is missing")
check(callable(module.decode_fragments), "decode_fragments is missing")
check(module.normalize_fragment("_open--gate_") == "OPEN GATE", "normalization failed")
check(module.normalize_fragment("__-") == "", "empty boundary failed")
check(
module.decode_fragments([" open-", "_the_", " lunar ", "-vault "]) == EXPECTED,
"museum phrase failed",
)
check(module.decode_fragments(["_open_", "", "---", "vault"]) == "OPEN VAULT", "empty fragment filtering failed")
def metadata_checks():
data = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))
project = data["project"]
check(project["name"] == "moonlight-cipher-kit", "distribution name is wrong")
check(project["version"] == "0.1.0", "version is wrong")
check(project["requires-python"] == ">=3.10", "Python range is wrong")
check(project["dependencies"] == [], "runtime dependencies must be empty")
check(data["build-system"]["build-backend"] == "hatchling.build", "backend is wrong")
check(project["scripts"]["mooncipher"] == "mooncipher.cli:main", "script target is wrong")
def installed_checks():
with TemporaryDirectory() as name:
outside = Path(name)
api = run(
[sys.executable, "-c", "from mooncipher import decode_fragments, normalize_fragment; print(decode_fragments(['open', 'the', 'lunar', 'vault'])); print(normalize_fragment('_moon-gate_'))"],
outside,
)
check(api.returncode == 0, api.stderr)
check(api.stdout.splitlines() == [EXPECTED, "MOON GATE"], "public API or quiet import failed")
module = run([sys.executable, "-m", "mooncipher"], outside)
check(module.returncode == 0, module.stderr)
check(module.stdout.strip() == EXPECTED, "module entry point failed")
command = run([command_path(Path(sys.prefix), "mooncipher")], outside)
check(command.returncode == 0, command.stderr)
check(command.stdout.strip() == EXPECTED, "console command failed")
def artifact_checks():
wheels = sorted((ROOT / "dist").glob("*.whl"))
check(len(wheels) == 1, f"expected one wheel, found {len(wheels)}")
wheel = wheels[0].resolve()
with ZipFile(wheel) as archive:
names = archive.namelist()
check("mooncipher/decoder.py" in names, "decoder is absent from wheel")
check("mooncipher/cli.py" in names, "CLI is absent from wheel")
check(not any("__pycache__" in name or name.startswith("checks/") for name in names), "development artifacts entered wheel")
metadata_name = next(name for name in names if name.endswith(".dist-info/METADATA"))
metadata = BytesParser(policy=default).parsebytes(archive.read(metadata_name))
check(metadata["Name"] == "moonlight-cipher-kit" and metadata["Version"] == "0.1.0", "wheel metadata is wrong")
with TemporaryDirectory() as name:
root = Path(name)
environment = root / "clean-env"
outside = root / "outside"
outside.mkdir()
subprocess.run([sys.executable, "-m", "venv", str(environment)], check=True)
python = environment_python(environment)
install = run([python, "-m", "pip", "install", wheel], outside)
check(install.returncode == 0, install.stderr)
proof = run([python, "-m", "mooncipher"], outside)
check(proof.returncode == 0 and proof.stdout.strip() == EXPECTED, proof.stderr or proof.stdout)
console = run([command_path(environment, "mooncipher")], outside)
check(console.returncode == 0 and console.stdout.strip() == EXPECTED, console.stderr or console.stdout)
STAGES = {
"source": source_checks,
"metadata": metadata_checks,
"installed": installed_checks,
"artifact": artifact_checks,
}
if __name__ == "__main__":
stage = sys.argv[1] if len(sys.argv) == 2 else ""
if stage not in STAGES:
raise SystemExit("usage: python checks/check_release.py source|metadata|installed|artifact")
STAGES[stage]()
print(f"{stage}: {checks_run} checks passed")
"""
),
}
for relative_path, content in files.items():
destination = root / relative_path
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(content, encoding="utf-8")
print(f"Created {len(files)} files under {root.resolve()}")The starter has seven seeded defects:
decoder.pyprints the secret while being imported;__init__.pydoes not expose the two public functions;cli.pylaunchesmain()during import;__main__.pyrefers tomainbut never calls it behind an execution guard;requires-pythonexcludes the course interpreter;- the installed script points to nonexistent
launch; and .gitignoreomits environments, build output, and caches.
Do not add an eighth workaround such as sys.path.insert. Repair the owning contract.
4. Build in small stages
Work from the new moonlight-cipher-kit directory.
Stage A: make source imports quiet
Remove import-time action from decoder.py. Keep the two function definitions there. The source stage loads that file directly, so it can run before package installation:
Expected final result for this stage:
Stage B: repair metadata before creating the project environment
Correct requires-python and the script target, then run:
Expected:
Stage C: create one development environment and install editable source
On Windows, use .venv\Scripts\python.exe. Prove identity before continuing:
Repair the package facade, remove the unguarded call from cli.py, and make __main__.py call main() only when executed as the top-level module. Then run the installed stage through the environment interpreter:
Expected:
Stage D: protect the repository boundary
Expand .gitignore, initialize the disposable repository, and inspect before committing:
Configure a local course identity first if Git requests one. The staged diff must exclude .venv/, dist/, build/, caches, and installed metadata.
Stage E: build one clean artifact set
Install the build frontend as development tooling, remove stale output, and build:
The final artifact stage creates its own second environment and installs the exact wheel. It may take longer than the earlier stages:
Expected:
Windows learners can remove dist and build with PowerShell before running the same Python check command.
5. Run progressive assertions
The harness groups 29 automated checks. Add this final Git inspection as check 30:
Use the list to see progress even before a whole group passes.
Source contract — checks 1–9
Metadata contract — checks 10–15
Development installation — checks 16–21
Wheel and clean installation — checks 22–29
Repository boundary — check 30
If a group stops on the first assertion, repair it and rerun. The displayed count will advance as earlier checks pass.
6. Use the hint ladder only when needed
Hint 1
There should be exactly one owner for decoding and one owner for command action. decoder.py should contain definitions only. The package root imports the two supported functions. Both launch mechanisms should eventually call mooncipher.cli.main.
Draw these arrows before editing:
No arrow needs to point back upward.
Hint 2
An imported module should not call its own demo. In cli.py, retain the main(argv=None) definition but remove the unguarded final call. In __main__.py, import main and put the call under the same __name__ guard used in the modules lesson. In pyproject.toml, the script target uses module:attribute without parentheses.
Hint 3
The package initializer needs explicit relative re-exports and __all__. The Python requirement is the course minimum, not a fictional future version. A useful ignore file covers .venv/, __pycache__/, *.py[cod], *.egg-info/, build/, dist/, and common tool caches. After every metadata or source repair, rerun editable installation before judging its installed entry points.
7. Keep debugging evidence
Preserve one failure that changed your understanding. A useful record has this shape:
| Field | Your evidence |
|---|---|
| Stage and command | for example, .venv/bin/python checks/check_release.py installed |
| Interpreter | output of sys.executable |
| Working directory | absolute project path or temporary outside path |
| Observed result | return code plus exact stdout/stderr |
| Contract | what should have been quiet or printed exactly once |
| Hypothesis | one ownership, metadata, environment, or artifact explanation |
| Controlled repair | one changed file and why it owns the contract |
| Regression proof | rerun stage plus one later stage |
For example, two copies of the secret phrase suggest two execution paths, not a string-normalization bug. A traceback naming launch suggests entry-point metadata, not a missing wheel. A Requires-Python refusal points at compatibility metadata before source execution begins.
Do not erase the failed output after repairing it. The contrast between failure and proof is part of the challenge result.
8. Compare with a complete solution
Show every final project file after attempting all stages
.gitignore:
README.md:
pyproject.toml:
[build-system]
requires = ["hatchling>=1.26"]
build-backend = "hatchling.build"
[project]
name = "moonlight-cipher-kit"
version = "0.1.0"
description = "Decode fragments from a moonlit puzzle vault."
readme = "README.md"
requires-python = ">=3.10"
dependencies = []
[project.scripts]
mooncipher = "mooncipher.cli:main"
[tool.hatch.build.targets.wheel]
packages = ["src/mooncipher"]src/mooncipher/decoder.py:
def normalize_fragment(value):
"""Return uppercase words separated by one space."""
separated = value.replace("-", " ").replace("_", " ")
return " ".join(separated.split()).upper()
def decode_fragments(values):
"""Normalize fragments and join the non-empty results."""
cleaned = [normalize_fragment(value) for value in values]
return " ".join(value for value in cleaned if value)src/mooncipher/__init__.py:
src/mooncipher/cli.py:
import sys
from .decoder import decode_fragments
DEFAULT_FRAGMENTS = [" open-", "_the_", " lunar ", "-vault "]
def main(argv=None):
"""Decode supplied fragments, or the museum fixture, and print it."""
fragments = list(sys.argv[1:] if argv is None else argv)
print(decode_fragments(fragments or DEFAULT_FRAGMENTS))src/mooncipher/__main__.py:
Keep the starter checks/check_release.py unchanged. It is development support, not part of the import package. Run all four stages again from clean source and verify the staged Git diff excludes generated paths.
9. Adapt to a changed museum rule
The museum finds a fifth fragment, _TONIGHT_. Prepare release 0.2.0 so the default phrase becomes:
Requirements:
- change the version in project metadata;
- extend
DEFAULT_FRAGMENTSrather than hard-coding a new final phrase; - keep
decode_fragmentsand its original assertions compatible; - update the expected fixture in the check harness;
- remove old
dist/artifacts before rebuilding; - inspect wheel metadata for
0.2.0; and - clean-install the new exact wheel and prove both entry routes outside the repository.
Explain why changing only EXPECTED in the harness would manufacture a failure rather than implement the new product rule. Then create one focused Git commit whose staged diff contains the version, fixture, and expectation changes but no wheel or environment.
10. Check your understanding
11. Decide whether the challenge is complete
Record completion only after:
- source, metadata, installed, and artifact stages all pass;
- check 30 confirms generated paths stayed out of Git;
- imports are quiet and both entry routes print exactly one phrase;
- the wheel’s package files and metadata were inspected;
- the clean installation ran outside the repository;
- one debugging record contains before-and-after evidence; and
- the
0.2.0changed rule also passes from a rebuilt, clean-installed wheel.
This button records progress only in this browser. It does not submit work, grade it, verify identity, or issue a certificate.
Not yet recorded.
Key points
- Reusable modules define behavior without launching demonstrations at import.
- Package facades and both entry routes should point toward one-directional ownership, not duplicate the secret phrase.
- Metadata errors can prevent installation before product source runs; diagnose the layer named by the evidence.
- Environment Python, module origin, wheel contents, and installed metadata are stronger proof than a prompt or successful build message.
- A fresh environment outside the repository tests the artifact a user actually receives.
- Git should preserve deliberate source and configuration, not disposable environments, caches, or local build output.