{
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
Move Records Between CSV, JSON, YAML, and Python
Choose a structured-text format, use its reader and writer correctly, convert types explicitly, and validate parsed values before trusting them.
python-foundations
files-paths-external-data
csv
json
yaml
Course progress
0%
A neighborhood festival receives booth registrations from a spreadsheet, saves a processed snapshot for another program, and keeps a small human-edited configuration. Those three jobs look like “store some records,” yet they have different shapes and different readers:
- CSV is naturally tabular and exchanges well with spreadsheets;
- JSON represents nested combinations of objects, arrays, strings, numbers, booleans, and null; and
- YAML is convenient for human-edited configuration but has a larger grammar and requires a third-party Python library here.
The format parser answers “Is this valid CSV/JSON/YAML syntax?” Your application must still answer “Is this the kind of festival record we accept?” Keep those two questions separate.
1. Choose a format from the data and its consumers
| Need | CSV | JSON | YAML |
|---|---|---|---|
| rows with a shared set of columns | strong fit | possible, more punctuation | possible |
| deeply nested data | awkward | strong fit | strong fit |
| spreadsheet exchange | common | less convenient | uncommon |
| human-edited comments | no standard comment facility | no comments | supported |
| Python standard library | csv |
json |
no; this course uses PyYAML |
| types after parsing | fields are strings (or None in special cases) |
defined JSON-to-Python mapping | loader-dependent scalar typing |
A filename suffix communicates a convention; it does not validate the contents. Use the format’s parser, then validate the resulting Python values.
TipWrite the interchange contract down
Record the encoding, newline policy, fields/keys, permitted types, missing-value rules, and version expectations. “It’s a CSV” is not enough: CSV has dialect, header, quoting, and application-schema choices.
2. CSV quoting defeats manual comma splitting
Consider this valid CSV text:
The first description contains a quoted comma. The second contains a quoted newline, so one logical record spans two physical lines. Splitting strings on commas or newlines cannot implement the CSV grammar correctly.
Use csv.reader for positional rows or csv.DictReader when the first row names fields:
Every ordinary CSV field is text. "40" is not automatically the integer 40. Convert under an application rule and add context to failure:
csv.reader is useful when position is the public contract or when a file has no header. Dialect options belong to the reader or writer rather than string replacement:
Only choose a non-default delimiter because the producer’s dialect specifies it. Python cannot infer every CSV variation reliably from one short sample.
def parse_capacity(raw_value, source, record_number):
"""Return a positive integer capacity with source context."""
try:
capacity = int(raw_value)
except (TypeError, ValueError) as error:
raise ValueError(
f"{source}: record {record_number}: invalid capacity {raw_value!r}"
) from error
if capacity <= 0:
raise ValueError(
f"{source}: record {record_number}: capacity must be positive"
)
return capacity
print(parse_capacity(rows[0]["capacity"], "booths.csv", 1))reader.line_num counts physical lines read, which is not always the same as a logical record number when quoted fields contain newlines. Track a logical record number with enumerate(reader, start=1) and include both kinds of context when useful.
3. Open CSV files with an empty newline setting
The csv documentation requires files to be opened with newline="". That lets the CSV module, rather than the text layer, own CSV newline handling. Continue to state the encoding.
from pathlib import Path
from tempfile import TemporaryDirectory
format_temporary_directory = TemporaryDirectory()
workspace = Path(format_temporary_directory.name)
csv_path = workspace / "booths.csv"
csv_path.write_text(csv_text, encoding="utf-8", newline="")
with csv_path.open(encoding="utf-8", newline="") as handle:
reader = csv.DictReader(handle)
print("headers:", reader.fieldnames)
file_rows = list(reader)
assert len(file_rows) == 2Validate headers before trusting rows:
EXPECTED_HEADERS = ["booth_id", "name", "description", "capacity"]
def require_csv_headers(fieldnames, source):
"""Raise when CSV headers do not exactly match the versioned contract."""
if fieldnames != EXPECTED_HEADERS:
raise ValueError(
f"{source}: expected headers {EXPECTED_HEADERS!r}; got {fieldnames!r}"
)An exact-order policy is easy to explain but may be stricter than some systems need. Another application might allow any order while rejecting missing and extra names separately. Decide deliberately. DictReader uses None as a key for surplus fields and restval for missing ones; check rather than assuming a short or long row is harmless.
Make those shapes visible with a controlled input:
These sentinels expose malformed row shape. They do not decide whether extra or missing fields are permitted; validation owns that decision.
Write through csv.writer or csv.DictWriter so quoting is handled correctly:
output_path = workspace / "accepted-booths.csv"
output_rows = [
{
"booth_id": "B-101",
"name": "Café Aurora",
"description": "Coffee, tea, and cakes",
"capacity": 40,
},
{
"booth_id": "B-102",
"name": "月の屋台",
"description": "Games on two\nlevels",
"capacity": 25,
},
]
with output_path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=EXPECTED_HEADERS)
writer.writeheader()
writer.writerows(output_rows)
with output_path.open(encoding="utf-8", newline="") as handle:
restored_rows = list(csv.DictReader(handle))
assert restored_rows[0]["description"] == "Coffee, tea, and cakes"
assert restored_rows[0]["capacity"] == "40"The integer became CSV text and comes back as a string. That is expected format behavior, not a failed round trip. Define equality in terms of the interchange contract, not Python object identity.
csv.writer writes sequences, while DictWriter selects named fields in its declared order. Values are converted to text; notably, the module writes None as an empty field, which cannot be reversed without a separate missing-value contract:
If None and an intentionally empty string mean different things, choose an explicit sentinel or schema instead of assuming CSV preserves the distinction.
Checkpoint: read tables as CSV, not punctuation
4. JSON maps a defined set of values
JSON supports objects, arrays, strings, numbers, booleans, and null. Python’s json module maps them approximately as follows:
| JSON | Python after loading |
|---|---|
| object | dict |
| array | list |
| string | str |
| integer number | int |
| non-integer number | float |
true / false |
True / False |
null |
None |
loads and dumps work with strings. load and dump work with open file-like objects:
import json
festival = {
"name": "Festival da Lua",
"open": True,
"visitor_limit": 500,
"booths": ["Café Aurora", "月の屋台"],
"closed_at": None,
}
json_text = json.dumps(
festival,
ensure_ascii=False,
indent=2,
sort_keys=True,
)
restored = json.loads(json_text)
print(json_text)
assert restored == festivalensure_ascii=False keeps non-ASCII characters readable in the Unicode output string. Encoding remains a separate file operation; write that string as UTF-8. indent=2 makes a human-readable artifact, while sort_keys=True makes object key order deterministic for comparisons. Neither option validates the data.
Some Python values have no direct JSON representation:
Convert intentionally at your application’s boundary—for example, a path to a string and a set to a sorted list. Avoid a catch-all default=str when it would turn unsupported or accidental objects into ambiguous text.
Even supported-looking containers can change representation:
A tuple returns as a list, and an integer object key returns as the string "7", because JSON arrays do not record Python tuple identity and JSON object names are strings. Dates and times also need an explicit representation such as an ISO 8601 string plus a documented timezone policy. Test semantic round trips for the data model your application actually promises.
5. JSON parsing failures include location evidence
Malformed JSON raises JSONDecodeError, a subclass of ValueError:
The parser error says where JSON grammar failed. If parsing succeeds but capacity is negative or booths is a string instead of a list, that is an application validation failure and should have a different message.
Use one dump per complete JSON document. JSON is not a framed protocol, so calling dump repeatedly on one stream normally concatenates invalid documents:
If you need a sequence, write one JSON array, adopt a specified format such as newline-delimited JSON, or use another framing protocol. State which contract readers expect.
Untrusted JSON can consume substantial CPU and memory through huge inputs or deep structures. Limit input size at the surrounding system boundary when that risk matters; successful JSON grammar parsing is not a resource-safety proof.
Write a complete deterministic document with a final newline, then parse it back before publishing:
archive_path = workspace / "festival.json"
archive_text = json.dumps(
festival,
ensure_ascii=False,
indent=2,
sort_keys=True,
) + "\n"
archive_path.write_text(archive_text, encoding="utf-8", newline="\n")
with archive_path.open(encoding="utf-8") as handle:
restored_from_file = json.load(handle)
assert restored_from_file == festivalCheckpoint: make JSON output predictable
6. YAML is a third-party, human-edited boundary
This course uses PyYAML 6 or newer as a direct project dependency. In a fresh standalone notebook, the following hidden setup cell installs it only when it is absent:
Now load the public API:
For untrusted or ordinary configuration, use safe_load, not a loader capable of constructing arbitrary Python objects:
Safe loading restricts object construction. It does not prove that the result is a mapping, that required keys exist, or that values satisfy festival rules. Even an empty YAML document can parse to None.
def validate_configuration(value):
"""Return a small validated festival configuration."""
if not isinstance(value, dict):
raise ValueError("configuration must be a mapping")
required = {"festival_name", "open", "visitor_limit", "theme"}
missing = required - value.keys()
extra = value.keys() - required
if missing:
raise ValueError(f"missing configuration keys: {sorted(missing)!r}")
if extra:
raise ValueError(f"unexpected configuration keys: {sorted(extra)!r}")
if not isinstance(value["festival_name"], str) or not value["festival_name"].strip():
raise ValueError("festival_name must be non-empty text")
if type(value["open"]) is not bool:
raise ValueError("open must be a boolean")
if not isinstance(value["visitor_limit"], int) or isinstance(
value["visitor_limit"], bool
):
raise ValueError("visitor_limit must be an integer")
if value["visitor_limit"] <= 0:
raise ValueError("visitor_limit must be positive")
if not isinstance(value["theme"], dict):
raise ValueError("theme must be a mapping")
return value
validated_configuration = validate_configuration(configuration)The explicit type(... ) is not bool check matters because bool is a subclass of int in Python. Domain contracts sometimes need stricter distinctions than isinstance(value, int) alone.
Write with safe_dump:
allow_unicode=True keeps multilingual text readable. YAML formatting and scalar interpretation can vary by library and version, so compare validated values rather than requiring one hand-written spacing style unless textual form is itself the contract.
Inspect scalar typing rather than assuming every unquoted value remains text:
Quoting 007 preserves it as a label instead of inviting numeric interpretation. PyYAML can also construct date-like scalars as date objects. Treat such behavior as part of the chosen loader/version contract, and validate types immediately after loading.
7. Compare the same record across format boundaries
Suppose one accepted booth is:
CSV needs a policy for turning features into one cell or a related table. JSON and YAML can represent the list directly. YAML can carry human comments, but those comments normally do not survive a safe_load/safe_dump value round trip. JSON is broadly interoperable but deliberately has no comment syntax. No format preserves Python classes, aliases, validators, or business meaning automatically.
Use this decision record before implementation:
| Question | Example answer for the festival |
|---|---|
| Who produces and consumes it? | spreadsheet staff produce booth rows; programs consume the archive |
| What is the natural shape? | flat rows for intake, nested values for snapshot |
| Must people edit it? | YAML configuration is reviewed by coordinators |
| Which types must survive? | capacity is validated as integer; feature order is meaningful |
| How is invalid data represented? | raw CSV row plus source, logical record, and reason |
| What makes output reproducible? | stable source order, key order, field order, encoding, and newline policy |
8. Build one festival conversion pipeline
Create three files in a temporary workspace:
incoming/booths.csvwith a quoted comma, a quoted multiline description, one invalid capacity, and multilingual names;config/festival.ymlwith a positive visitor limit and theme; andoutput/festival.json, which does not exist yet.
Build functions with these responsibilities:
def load_booths(path):
"""Return (accepted, rejected) booth records from a UTF-8 CSV file."""
# Validate headers, enumerate logical records, and convert capacity.
...
def load_festival_configuration(path):
"""Return validated configuration loaded with yaml.safe_load."""
...
def build_festival_archive(configuration, booths):
"""Return the JSON-compatible archive value."""
return {"configuration": configuration, "booths": booths}Requirements:
- Open CSV with
encoding="utf-8"andnewline="". - Require the exact header contract.
- Preserve the raw CSV row and a useful reason for each rejected record.
- Do not catch unexpected exceptions from programming defects.
- Load YAML safely, then validate its Python structure.
- Dump one JSON document with visible Unicode, indentation, deterministic keys, and one final newline.
- Parse the output back and compare it to the Python archive.
- Rerun the pipeline and prove the serialized text is identical.
Compare the boundary order after completing your version
def load_booths(path):
accepted = []
rejected = []
path = Path(path)
with path.open(encoding="utf-8", newline="") as handle:
reader = csv.DictReader(handle)
require_csv_headers(reader.fieldnames, path.name)
for record_number, row in enumerate(reader, start=1):
try:
booth_id = row["booth_id"].strip()
name = row["name"].strip()
if not booth_id or not name:
raise ValueError("booth_id and name must be non-empty")
capacity = parse_capacity(
row["capacity"], path.name, record_number
)
except ValueError as error:
rejected.append(
{
"source": path.name,
"record": record_number,
"raw": dict(row),
"reason": str(error),
}
)
else:
accepted.append(
{
"booth_id": booth_id,
"name": name,
"description": row["description"],
"capacity": capacity,
}
)
return accepted, rejected
def load_festival_configuration(path):
with Path(path).open(encoding="utf-8") as handle:
return validate_configuration(yaml.safe_load(handle))Here is a complete caller using a fresh fixture and a parse-back check:
integration_root = workspace / "integration"
incoming = integration_root / "incoming"
configuration_directory = integration_root / "config"
output_directory = integration_root / "output"
for directory in [incoming, configuration_directory, output_directory]:
directory.mkdir(parents=True, exist_ok=True)
(incoming / "booths.csv").write_text(
(
"booth_id,name,description,capacity\n"
'B-101,Café Aurora,"Coffee, tea",40\n'
'B-102,月の屋台,"Games on two\nlevels",25\n'
"B-103,Broken Booth,Invalid capacity,many\n"
),
encoding="utf-8",
newline="",
)
(configuration_directory / "festival.yml").write_text(
yaml_text,
encoding="utf-8",
)
accepted_booths, rejected_booths = load_booths(incoming / "booths.csv")
festival_configuration = load_festival_configuration(
configuration_directory / "festival.yml"
)
complete_archive = {
**build_festival_archive(festival_configuration, accepted_booths),
"rejected": rejected_booths,
}
complete_text = json.dumps(
complete_archive,
ensure_ascii=False,
indent=2,
sort_keys=True,
) + "\n"
complete_path = output_directory / "festival.json"
complete_path.write_text(complete_text, encoding="utf-8", newline="\n")
with complete_path.open(encoding="utf-8") as handle:
parsed_archive = json.load(handle)
assert parsed_archive == complete_archive
assert len(parsed_archive["booths"]) == 2
assert len(parsed_archive["rejected"]) == 1
assert "Café Aurora" in complete_text
assert complete_text.endswith("\n")The caller keeps CSV parsing, YAML configuration validation, archive assembly, and JSON verification visibly separate. Lesson 2’s temporary replacement helper can guard the local text write; the challenge combines JSON parse-back verification with replacement directly.
Checkpoint: choose the complete format boundary
9. Key points for structured records
- CSV, JSON, and YAML solve overlapping but different interchange problems; choose from data shape, producers, consumers, and trust boundary.
- Use the CSV module for quoting and multiline records, open files with
newline="", validate headers, and convert field types explicitly. json.loads/dumpswork with in-memory serialized values;load/dumpwork with file-like objects. JSON parsing is not domain validation or framing.- Use
ensure_ascii=Falseplus UTF-8 for readable multilingual JSON, and select formatting or key order when deterministic output matters. - Use PyYAML’s
safe_loadandsafe_dump, but validate the resulting Python value just as carefully as data from another parser. - Preserve source, logical record, raw values, and reason for anticipated rejected data. Let unexpected programming defects remain visible.
10. References and next step
- Python documentation: CSV file reading and writing
- Python documentation: JSON encoding and decoding
- PyYAML documentation: safe loading and dumping
Next, strengthen the validation stage. You will turn messy text into accepted records or explainable rejections by separating normalization, recognition, extraction, conversion, structural rules, and domain rules.