Python Writing Files Practice Problems & Exercises
Practice: Writing Files
← Back to lessonEasy
Write a function write_greeting that opens a file in 'w' mode and writes "Hello, <name>!" (replacing the placeholder with the actual name parameter). Use encoding="utf-8".
This tests the most fundamental file writing pattern: open, write, close via a context manager.
import os, tempfile
def write_greeting(filepath, name):
with open(filepath, "w", encoding="utf-8") as f:
f.write("Hello, " + name + "!")
# Test
path = os.path.join(tempfile.gettempdir(), "greeting.txt")
write_greeting(path, "Alice")
with open(path, "r", encoding="utf-8") as f:
print(f.read())
os.remove(path)Solution
def write_greeting(filepath, name):
with open(filepath, "w", encoding="utf-8") as f:
f.write("Hello, " + name + "!")
Key points:
'w'mode creates the file if it does not exist, or truncates it if it does.write()returns the number of characters written but does not add a newline.- Always specify
encoding="utf-8"to avoid platform-dependent behavior.
import os, tempfile
def write_greeting(filepath, name):
# TODO: Write "Hello, <name>!" to the file using 'w' mode
# Include encoding="utf-8"
pass
# Test
path = os.path.join(tempfile.gettempdir(), "greeting.txt")
write_greeting(path, "Alice")
with open(path, "r", encoding="utf-8") as f:
print(f.read())
os.remove(path)Expected Output
Hello, Alice!Hints
Hint 1: Open the file with open(filepath, 'w', encoding='utf-8') inside a with statement.
Hint 2: Use f.write() to write the greeting string. Remember write() does not add a newline automatically.
Write a function append_log that opens a file in 'a' mode and appends a single message followed by a newline.
Calling append_log three times should produce three lines in the file. This tests the difference between 'w' (truncate) and 'a' (append) mode.
import os, tempfile
def append_log(filepath, message):
with open(filepath, "a", encoding="utf-8") as f:
f.write(message + "\n")
# Test
path = os.path.join(tempfile.gettempdir(), "app.log")
if os.path.exists(path):
os.remove(path)
append_log(path, "Server started")
append_log(path, "Request received")
append_log(path, "Response sent")
with open(path, "r", encoding="utf-8") as f:
print(f.read(), end="")
os.remove(path)Solution
def append_log(filepath, message):
with open(filepath, "a", encoding="utf-8") as f:
f.write(message + "\n")
Key points:
'a'mode preserves existing content and positions the cursor at the end.- Each call to
append_logopens and closes the file independently — the content accumulates. - On POSIX systems,
'a'mode usesO_APPEND, making individual writes atomic at the OS level.
import os, tempfile
def append_log(filepath, message):
# TODO: Append the message to the file followed by a newline
# Use 'a' mode with encoding="utf-8"
pass
# Test
path = os.path.join(tempfile.gettempdir(), "app.log")
# Remove if exists from previous run
if os.path.exists(path):
os.remove(path)
append_log(path, "Server started")
append_log(path, "Request received")
append_log(path, "Response sent")
with open(path, "r", encoding="utf-8") as f:
print(f.read(), end="")
os.remove(path)Expected Output
Server started
Request received
Response sentHints
Hint 1: Open the file with mode 'a' to append without truncating existing content.
Hint 2: Add a newline character after the message: f.write(message + '\n').
Write a function write_names that takes a filepath and a list of names, and writes each name on its own line using writelines().
The key insight: writelines() does not add newlines automatically — you must add them yourself.
import os, tempfile
def write_names(filepath, names):
with open(filepath, "w", encoding="utf-8") as f:
f.writelines(name + "\n" for name in names)
# Test
path = os.path.join(tempfile.gettempdir(), "names.txt")
write_names(path, ["Alice", "Bob", "Charlie"])
with open(path, "r", encoding="utf-8") as f:
print(f.read(), end="")
os.remove(path)Solution
def write_names(filepath, names):
with open(filepath, "w", encoding="utf-8") as f:
f.writelines(name + "\n" for name in names)
Key points:
writelines()accepts any iterable of strings. A generator expression avoids building a full list in memory.- The name is misleading:
writelines()does not add line separators. It simply callswrite()for each item. - Using a generator (
name + "\n" for name in names) is memory-efficient for large lists.
import os, tempfile
def write_names(filepath, names):
# TODO: Write each name on its own line using writelines()
# Remember: writelines() does NOT add newlines for you
pass
# Test
path = os.path.join(tempfile.gettempdir(), "names.txt")
write_names(path, ["Alice", "Bob", "Charlie"])
with open(path, "r", encoding="utf-8") as f:
print(f.read(), end="")
os.remove(path)Expected Output
Alice
Bob
CharlieHints
Hint 1: writelines() writes each element of the iterable as-is, without adding newlines.
Hint 2: You need to add '\n' to each name before passing to writelines. A generator expression works well.
Write a function safe_create that writes content to a file using 'x' (exclusive create) mode. If the file already exists, it should print "File already exists: <filepath>" and return False. If the file was created successfully, return True.
This tests the race-condition-free file creation pattern that avoids TOCTOU bugs.
import os, tempfile
def safe_create(filepath, content):
try:
with open(filepath, "x", encoding="utf-8") as f:
f.write(content)
return True
except FileExistsError:
print("File already exists: " + filepath)
return False
# Test
path = os.path.join(tempfile.gettempdir(), "unique_file.txt")
if os.path.exists(path):
os.remove(path)
result1 = safe_create(path, "original data")
print(result1)
result2 = safe_create(path, "overwrite attempt")
print(result2)
with open(path, "r", encoding="utf-8") as f:
print(f.read())
os.remove(path)Solution
def safe_create(filepath, content):
try:
with open(filepath, "x", encoding="utf-8") as f:
f.write(content)
return True
except FileExistsError:
print("File already exists: " + filepath)
return False
Key points:
'x'mode maps toO_CREAT | O_EXCLat the OS level — a single atomic system call.- This is safer than
if not os.path.exists(path): open(path, 'w')which has a TOCTOU race condition. FileExistsErroris a subclass ofOSErrorand is specific to this exact scenario.
import os, tempfile
def safe_create(filepath, content):
# TODO: Write content to filepath using 'x' mode
# If file already exists, print "File already exists: <filepath>"
# and do NOT overwrite it
# Return True if created, False if it already existed
pass
# Test
path = os.path.join(tempfile.gettempdir(), "unique_file.txt")
# Clean up from previous run
if os.path.exists(path):
os.remove(path)
result1 = safe_create(path, "original data")
print(result1)
result2 = safe_create(path, "overwrite attempt")
print(result2)
with open(path, "r", encoding="utf-8") as f:
print(f.read())
os.remove(path)Expected Output
True
File already exists: /tmp/unique_file.txt
False
original dataHints
Hint 1: Use 'x' mode in the open() call — it raises FileExistsError if the file exists.
Hint 2: Wrap the open() in a try/except FileExistsError block.
Medium
Write a function demonstrate_truncation that:
- Writes
"AAAAAAAAAA"(10 A characters) to the file in'w'mode - Opens the same file again in
'w'mode and writes"BB"(2 B characters) - Reads and returns the file contents
The returned string should be 'BB' (length 2), not 'BBAAAAAAAA'. This demonstrates that 'w' mode truncates the file to zero length at open() time.
import os, tempfile
def demonstrate_truncation(filepath):
with open(filepath, "w", encoding="utf-8") as f:
f.write("AAAAAAAAAA")
with open(filepath, "w", encoding="utf-8") as f:
f.write("BB")
with open(filepath, "r", encoding="utf-8") as f:
return f.read()
# Test
path = os.path.join(tempfile.gettempdir(), "trunc_test.txt")
result = demonstrate_truncation(path)
print(repr(result))
print(len(result))
os.remove(path)Solution
def demonstrate_truncation(filepath):
with open(filepath, "w", encoding="utf-8") as f:
f.write("AAAAAAAAAA")
with open(filepath, "w", encoding="utf-8") as f:
f.write("BB")
with open(filepath, "r", encoding="utf-8") as f:
return f.read()
Key points:
'w'mode truncates the file immediately atopen()— beforewrite()is called.- The file becomes empty at the moment you call
open(path, "w"). If a crash happens before the newwrite(), you lose all data. - This is why production systems use the atomic write pattern (write to temp, then
os.replace()).
import os, tempfile
def demonstrate_truncation(filepath):
# Step 1: Write "AAAAAAAAAA" (10 A's) to the file in 'w' mode
# Step 2: Open the SAME file in 'w' mode and write "BB" (2 B's)
# Step 3: Read and return the file contents
# TODO: implement all three steps
pass
# Test
path = os.path.join(tempfile.gettempdir(), "trunc_test.txt")
result = demonstrate_truncation(path)
print(repr(result))
print(len(result))
os.remove(path)Expected Output
'BB'
2Hints
Hint 1: Opening a file in 'w' mode truncates it to zero length BEFORE any write happens.
Hint 2: After step 2, the file contains only 'BB' — the 10 A's are completely gone.
Write a function write_numbered_lines that takes a filepath and a list of lines, and writes each line prefixed with its 1-based line number in the format "N: line text\n".
Use writelines() with a generator expression and enumerate().
import os, tempfile
def write_numbered_lines(filepath, lines):
with open(filepath, "w", encoding="utf-8") as f:
f.writelines(
str(i) + ": " + line + "\n"
for i, line in enumerate(lines, start=1)
)
# Test
path = os.path.join(tempfile.gettempdir(), "numbered.txt")
write_numbered_lines(path, ["Buy groceries", "Walk the dog", "Write code"])
with open(path, "r", encoding="utf-8") as f:
print(f.read(), end="")
os.remove(path)Solution
def write_numbered_lines(filepath, lines):
with open(filepath, "w", encoding="utf-8") as f:
f.writelines(
str(i) + ": " + line + "\n"
for i, line in enumerate(lines, start=1)
)
Key points:
enumerate(lines, start=1)yields(1, line), (2, line), ...tuples.- The generator expression builds each formatted line lazily — no intermediate list is created in memory.
writelines()iterates through the generator and callswrite()for each string.
import os, tempfile
def write_numbered_lines(filepath, lines):
# TODO: Write each line prefixed with its 1-based line number
# Format: "1: first line
2: second line
" etc.
# Use writelines() with a generator expression
pass
# Test
path = os.path.join(tempfile.gettempdir(), "numbered.txt")
write_numbered_lines(path, ["Buy groceries", "Walk the dog", "Write code"])
with open(path, "r", encoding="utf-8") as f:
print(f.read(), end="")
os.remove(path)Expected Output
1: Buy groceries
2: Walk the dog
3: Write codeHints
Hint 1: Use enumerate(lines, start=1) to get 1-based indices.
Hint 2: Build each formatted line with a newline at the end, then pass the generator to writelines().
Write a function write_with_levels that demonstrates the three layers of file writing:
f.write(data)— data is in Python's internal bufferf.flush()— data is moved to the OS page cacheos.fsync(f.fileno())— data is forced to physical disk
Return a tuple (True, True, True) after all three steps complete successfully, and also ensure the file contains the data.
import os, tempfile
def write_with_levels(filepath, data):
with open(filepath, "w", encoding="utf-8") as f:
f.write(data)
python_buffered = True
f.flush()
os_buffered = True
os.fsync(f.fileno())
on_disk = True
return (python_buffered, os_buffered, on_disk)
# Test
path = os.path.join(tempfile.gettempdir(), "sync_demo.txt")
result = write_with_levels(path, "critical transaction data")
print(result)
with open(path, "r", encoding="utf-8") as f:
print(f.read())
os.remove(path)Solution
def write_with_levels(filepath, data):
with open(filepath, "w", encoding="utf-8") as f:
f.write(data)
python_buffered = True
f.flush()
os_buffered = True
os.fsync(f.fileno())
on_disk = True
return (python_buffered, os_buffered, on_disk)
Key points:
f.write()puts data into Python'sBufferedWriterbuffer (8 KB by default). It may not reach the OS yet.f.flush()pushes the Python buffer to the OS kernel's page cache. Other processes can now read it, but a power failure can lose it.os.fsync(f.fileno())issues a system call that forces the OS to write its page cache to the physical disk and waits for confirmation. Only after this is data truly durable.f.fileno()returns the OS-level file descriptor (an integer) needed byos.fsync().
import os, tempfile
def write_with_levels(filepath, data):
# TODO:
# 1. Open the file in 'w' mode
# 2. Write the data string
# 3. Call flush() — this moves data from Python buffer to OS page cache
# 4. Call os.fsync() on the file descriptor — this forces data to disk
# 5. Return a tuple: (python_buffered, os_buffered, on_disk)
# where each value is True after that step completes
# The function should track these three states
pass
# Test
path = os.path.join(tempfile.gettempdir(), "sync_demo.txt")
result = write_with_levels(path, "critical transaction data")
print(result)
with open(path, "r", encoding="utf-8") as f:
print(f.read())
os.remove(path)Expected Output
(True, True, True)
critical transaction dataHints
Hint 1: After f.write(), data is in Python's internal buffer (python_buffered = True).
Hint 2: After f.flush(), data is in the OS page cache (os_buffered = True). After os.fsync(f.fileno()), it is on disk (on_disk = True).
Write a function create_line_buffered_log that opens a file with line buffering (buffering=1) and writes each entry followed by a newline. After each write, record the file size using os.path.getsize().
With line buffering, each newline triggers an automatic flush, so the file size grows after every line.
import os, tempfile
def create_line_buffered_log(filepath, entries):
sizes = []
with open(filepath, "w", encoding="utf-8", buffering=1) as f:
for entry in entries:
f.write(entry + "\n")
sizes.append(os.path.getsize(filepath))
return sizes
# Test
path = os.path.join(tempfile.gettempdir(), "line_buf.log")
sizes = create_line_buffered_log(path, ["INFO: start", "WARN: low memory", "ERROR: crash"])
print(sizes)
with open(path, "r", encoding="utf-8") as f:
print(f.read(), end="")
os.remove(path)Solution
def create_line_buffered_log(filepath, entries):
sizes = []
with open(filepath, "w", encoding="utf-8", buffering=1) as f:
for entry in entries:
f.write(entry + "\n")
sizes.append(os.path.getsize(filepath))
return sizes
Key points:
buffering=1in text mode means line-buffered: the internal buffer is flushed each time a\nis written.- This is useful for log files where you want real-time visibility — a
tail -fon the file sees each line immediately. - Without line buffering (default 8 KB buffer), data might not appear on disk until the buffer is full or the file is closed.
- Line buffering only works in text mode. In binary mode,
buffering=1means a 1-byte buffer (not line-buffered).
import os, tempfile
def create_line_buffered_log(filepath, entries):
# TODO:
# 1. Open the file in 'w' mode with line buffering (buffering=1)
# 2. For each entry, write it followed by a newline
# 3. After writing each entry, verify it is visible by reading
# the file size with os.path.getsize()
# 4. Return a list of file sizes after each write
pass
# Test
path = os.path.join(tempfile.gettempdir(), "line_buf.log")
sizes = create_line_buffered_log(path, ["INFO: start", "WARN: low memory", "ERROR: crash"])
print(sizes)
with open(path, "r", encoding="utf-8") as f:
print(f.read(), end="")
os.remove(path)Expected Output
[12, 29, 43]
INFO: start
WARN: low memory
ERROR: crashHints
Hint 1: Use buffering=1 in the open() call — this enables line buffering in text mode.
Hint 2: Line buffering means the buffer is flushed automatically whenever a newline character is written.
Hard
Implement atomic_write(filepath, content) using the write-to-temp-then-rename pattern:
- Create a temp file in the same directory as the target (required for atomicity)
- Write content, flush, and fsync
- Use
os.replace()to atomically swap the temp file into place - On failure, clean up the temp file
This is the production pattern for safely updating critical files without risk of corruption.
import os, tempfile, json
def atomic_write(filepath, content):
dir_path = os.path.dirname(os.path.abspath(filepath))
fd, tmp_path = tempfile.mkstemp(dir=dir_path, suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(content)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, filepath)
except Exception:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
# Test
path = os.path.join(tempfile.gettempdir(), "atomic_test.json")
atomic_write(path, json.dumps({"version": 1}, indent=2))
with open(path, "r", encoding="utf-8") as f:
print(f.read())
atomic_write(path, json.dumps({"version": 2, "updated": True}, indent=2))
with open(path, "r", encoding="utf-8") as f:
print(f.read())
os.remove(path)Solution
def atomic_write(filepath, content):
dir_path = os.path.dirname(os.path.abspath(filepath))
fd, tmp_path = tempfile.mkstemp(dir=dir_path, suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(content)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, filepath)
except Exception:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
Key points:
- The temp file must be in the same directory (same filesystem) as the target. Cross-filesystem renames are not atomic.
os.fdopen(fd, ...)wraps the raw OS file descriptor frommkstemp()into a Python file object.f.flush()thenos.fsync()ensures data is physically on disk before the rename.os.replace()is atomic on POSIX — readers always see either the complete old file or the complete new file.- The
exceptblock cleans up the temp file to avoid leaving orphaned.tmpfiles.
import os, tempfile, json
def atomic_write(filepath, content):
# TODO: Implement the atomic write pattern:
# 1. Create a temp file in the SAME directory as filepath
# using tempfile.mkstemp(dir=..., suffix=".tmp")
# 2. Write content to the temp file using os.fdopen()
# 3. Flush and fsync the temp file
# 4. Use os.replace() to atomically swap it into place
# 5. If anything fails, clean up the temp file
pass
# Test
path = os.path.join(tempfile.gettempdir(), "atomic_test.json")
# Write initial content
atomic_write(path, json.dumps({"version": 1}, indent=2))
with open(path, "r", encoding="utf-8") as f:
print(f.read())
# Overwrite atomically
atomic_write(path, json.dumps({"version": 2, "updated": True}, indent=2))
with open(path, "r", encoding="utf-8") as f:
print(f.read())
os.remove(path)Expected Output
{
"version": 1
}
{
"updated": true,
"version": 2
}Hints
Hint 1: Use os.path.dirname(os.path.abspath(filepath)) to get the directory for the temp file.
Hint 2: tempfile.mkstemp() returns (fd, tmp_path). Use os.fdopen(fd, 'w', encoding='utf-8') to get a file object.
Hint 3: Wrap the write in try/except — in the except block, delete the temp file with os.unlink(tmp_path).
Build a transform_pipeline that passes text through a chain of transformations using temporary files at each stage:
- Write input lines to a temp file
- For each transformation: read the file, apply the transform to each line, write to a new temp file, delete the old one
- Return the final transformed text
This tests NamedTemporaryFile with delete=False, file lifecycle management, and multi-stage processing.
import os, tempfile, io
def transform_pipeline(input_lines, transformations):
# Write initial input to a temp file
tmp = tempfile.NamedTemporaryFile(
mode="w", suffix=".txt", delete=False, encoding="utf-8"
)
tmp.writelines(line + "\n" for line in input_lines)
tmp.close()
current_path = tmp.name
for transform in transformations:
# Read current file
with open(current_path, "r", encoding="utf-8") as f:
lines = f.read().splitlines()
# Write transformed lines to new temp file
new_tmp = tempfile.NamedTemporaryFile(
mode="w", suffix=".txt", delete=False, encoding="utf-8"
)
new_tmp.writelines(transform(line) + "\n" for line in lines)
new_tmp.close()
# Delete old, advance to new
os.unlink(current_path)
current_path = new_tmp.name
# Read final result
with open(current_path, "r", encoding="utf-8") as f:
result = f.read()
os.unlink(current_path)
return result
# Test
lines = ["hello world", "foo bar baz", "python is great"]
transforms = [
str.upper,
str.title,
lambda line: line.replace(" ", "_"),
]
result = transform_pipeline(lines, transforms)
print(result, end="")Solution
def transform_pipeline(input_lines, transformations):
tmp = tempfile.NamedTemporaryFile(
mode="w", suffix=".txt", delete=False, encoding="utf-8"
)
tmp.writelines(line + "\n" for line in input_lines)
tmp.close()
current_path = tmp.name
for transform in transformations:
with open(current_path, "r", encoding="utf-8") as f:
lines = f.read().splitlines()
new_tmp = tempfile.NamedTemporaryFile(
mode="w", suffix=".txt", delete=False, encoding="utf-8"
)
new_tmp.writelines(transform(line) + "\n" for line in lines)
new_tmp.close()
os.unlink(current_path)
current_path = new_tmp.name
with open(current_path, "r", encoding="utf-8") as f:
result = f.read()
os.unlink(current_path)
return result
Key points:
delete=Falseis essential — without it, the file is deleted when closed, and you cannot reopen it for the next stage.- Each stage reads from the previous temp file and writes to a new one, then deletes the old one.
splitlines()removes the trailing newlines, so each transformation receives clean strings.- Always clean up temp files with
os.unlink()—mkstemp()andNamedTemporaryFile(delete=False)do not auto-delete.
import os, tempfile, io
def transform_pipeline(input_lines, transformations):
# TODO:
# 1. Write input_lines to a NamedTemporaryFile (delete=False, mode="w")
# 2. For each transformation function in the list:
# a. Read the current temp file contents
# b. Apply the transformation to each line
# c. Write the transformed lines to a NEW NamedTemporaryFile
# d. Delete the old temp file
# e. The new temp file becomes the current one
# 3. Read and return the final contents as a string
# 4. Clean up the final temp file
pass
# Test
lines = ["hello world", "foo bar baz", "python is great"]
transforms = [
str.upper,
str.title,
lambda line: line.replace(" ", "_"),
]
result = transform_pipeline(lines, transforms)
print(result, end="")Expected Output
Hello_World
Foo_Bar_Baz
Python_Is_GreatHints
Hint 1: Use tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False, encoding='utf-8') for each stage.
Hint 2: After writing, close the file, then reopen it for reading. NamedTemporaryFile with delete=False lets you control cleanup.
Hint 3: Track the current file path and delete old temp files with os.unlink() after each transformation stage.
Write two functions:
generate_table(output, headers, rows)— writes a formatted table to any file-like objectcapture_table(headers, rows)— usesio.StringIOto capture the table in memory and return it as a string
This tests the pattern of writing code that works with both real files and in-memory buffers — essential for testable I/O code.
import io
def generate_table(output, headers, rows):
header_line = " | ".join(headers)
output.write(header_line + "\n")
output.write("-" * len(header_line) + "\n")
for row in rows:
output.write(" | ".join(row) + "\n")
def capture_table(headers, rows):
buffer = io.StringIO()
generate_table(buffer, headers, rows)
return buffer.getvalue()
# Test
headers = ["Name", "Age", "City"]
rows = [
["Alice", "30", "New York"],
["Bob", "25", "London"],
["Charlie", "35", "Tokyo"],
]
table_str = capture_table(headers, rows)
print(table_str, end="")
print("---")
print("Captured", len(table_str), "characters")Solution
def generate_table(output, headers, rows):
header_line = " | ".join(headers)
output.write(header_line + "\n")
output.write("-" * len(header_line) + "\n")
for row in rows:
output.write(" | ".join(row) + "\n")
def capture_table(headers, rows):
buffer = io.StringIO()
generate_table(buffer, headers, rows)
return buffer.getvalue()
Key points:
generate_tableaccepts any file-like object — a real file,sys.stdout, orio.StringIO. This makes the function testable without disk I/O.io.StringIOimplements the same interface as a text file:write(),read(),seek(),getvalue().getvalue()returns the entire buffer contents without needing toseek(0)first — unlikeread()which depends on the current position.- This dependency-injection pattern (pass the output target in) is a hallmark of well-designed I/O code.
import io
def generate_table(output, headers, rows):
# TODO: Write a formatted table to the output file object
# 1. Write column headers separated by " | " with a newline
# 2. Write a separator line of dashes (length matches header line)
# 3. Write each row with values separated by " | " with a newline
# The function should accept ANY file-like object (real file or StringIO)
pass
def capture_table(headers, rows):
# TODO: Use io.StringIO to capture the table output in memory
# 1. Create a StringIO buffer
# 2. Call generate_table with the buffer
# 3. Return the captured string using getvalue()
pass
# Test
headers = ["Name", "Age", "City"]
rows = [
["Alice", "30", "New York"],
["Bob", "25", "London"],
["Charlie", "35", "Tokyo"],
]
# Capture in memory
table_str = capture_table(headers, rows)
print(table_str, end="")
print("---")
print("Captured", len(table_str), "characters")Expected Output
Name | Age | City
-----------------
Alice | 30 | New York
Bob | 25 | London
Charlie | 35 | Tokyo
---
Captured 73 charactersHints
Hint 1: Join headers with ' | ' and add a newline. Do the same for each row.
Hint 2: The separator length should match the header line (without the newline). Use '-' * len(header_line).
Hint 3: In capture_table, create an io.StringIO(), pass it to generate_table, then return buffer.getvalue().
