What you'll learn
Lesson 01 taught you to build a local project and Lesson 02 taught you to make it configurable from a command line. This lesson connects those skills to real filesystem operations and structured data. Our data is fictional, but the Python parsing and validation are real.
- Structure a file pipeline with explicit location, reading, parsing, validation, transformation and publication stages.
- Use pathlib to reason about relative paths, root boundaries and local file access without relying on text prefixes.
- Specify UTF-8 encodings, recognize BOM-bearing CSV exports and open CSV files with newline=''.
- Read and validate CSV headers and rows with DictReader, including quotes, extra fields and missing values.
- Normalize only known status values and report malformed, duplicate or unknown records separately.
- Parse and serialize stable JSON while validating schema, Unicode behavior and nonstandard numeric values.
- Bound file inputs and distinguish missing, unreadable and oversized files from valid empty exports.
- Stage output in the target folder and replace existing reports only after validation and an explicit write decision.
- Combine a CLI from Lesson 02 with an offline file pipeline that can be tested safely in both browser and local environments.
--write.Prerequisite: Complete Command-Line Tools & Configuration or know how to use argparse and run a local Python script. If CSV, JSON, exceptions or functions still feel unfamiliar, review the Python Fundamentals path before continuing.
Think in pipeline stages, not one giant script
A working file pipeline is more than calling open() and writing a result. A reliable tool has a clear sequence: locate the requested file, read it with an intentional encoding, parse its format, validate what the data means, transform accepted rows, then publish a report. Each stage should make its own result explicit so an invalid source cannot turn into a successful-looking empty report. Use fictional inventory exports first; never aim beginner scripts at production shares or customer data.
Inputs need a contract
Define expected filenames, encoding, required columns, accepted status values and maximum file size. A file that exists but has no expected header is not equivalent to a valid export with zero devices.
Separate functions by responsibility
Keep read_source(path), parse_inventory(text), build_report(rows), and write_report(output, data) separate. Test each with changed inputs so you can identify which stage failed.
Leave the source untouched
Read the export without overwriting it. Build a new report in a separate output location; dry-run or preview modes should reveal the proposed result without creating files.
def summarize(rows):
offline = sum(1 for row in rows if row['status'] == 'offline')
return {'total': len(rows), 'offline': offline}
print(summarize([{'status':'offline'},{'status':'online'}]))Why is a CSV file missing required columns different from a valid file containing no device rows? Reveal the explanation after predicting.
Missing columns mean the input doesn't meet the schema and the tool cannot interpret it reliably. An empty but valid CSV export can legitimately produce zero devices. Treating both as zero would hide a broken import.
What happens when the input is imperfect?
Change a fictional CSV export and follow it through six stages. This explorer models the expected decisions; the coding clinic below executes real Python file and CSV operations in an isolated browser filesystem.
Inspect the source text without changing it
host,status,site
SW-01,online,HQ
AP-02,offline,BR1
AP-02,online,BR1
FW-01,offline,"HQ, East"
BAD,maintenance,HQChoose the next stage to see how rows turn into a deterministic summary. All data stays inside this page. No live systems are contacted.
Use pathlib to locate and constrain files
pathlib.Path represents filesystem paths and works across operating systems. A relative path such as samples/inventory.csv is usually resolved against the process working directory, which can differ when launched from an editor, Task Scheduler or a service. Use a documented project root or explicit absolute input path rather than assuming the current directory. If a tool accepts a user-supplied filename under a known data folder, verify the resolved path stays inside that folder. Be aware that resolving paths and then opening them isn't a complete defense against a malicious actor changing symlinks concurrently; for this learning tool we use only trusted local files.
Relative and absolute paths
Path('samples/inventory.csv') refers to the current working directory; Path(__file__).resolve().parent refers to the location of the running script. Don't confuse them.
Check the resolved boundary
Let base = Path('samples').resolve(). Verify the target resolves under base, not merely that its text starts with the same letters: /reports and /reports-old are different directories.
Use the right operation
exists() tells whether an entry exists but doesn't establish that it is an ordinary readable file. Use is_file(), handle OSError when opening, and avoid deleting or rewriting unexpected paths.
from pathlib import Path
base = Path('samples').resolve()
candidate = (base / 'inventory.csv').resolve()
allowed = candidate.is_relative_to(base)
print(allowed)Why is `str(candidate).startswith(str(base))` an unsafe substitute for a proper path boundary check? Reveal the explanation after predicting.
String prefixes aren't path components. /reports-old may start with /reports even though it is outside the intended directory. Path.is_relative_to compares actual path components after resolution.
Identify path components before trusting a location
The safe-file challenge asks for checks that don't appear together in the main example. Use a resolved base, examine relative paths structurally, and reject `..` before joining and resolving.
from pathlib import Path
base = Path('/northstar/incoming').resolve()
name = Path('exports/inventory.csv')
allowed = (not name.is_absolute() and '..' not in name.parts)
target = (base / name).resolve()
print(allowed and target != base and target.is_relative_to(base))Path.parts checks components; Path.is_absolute rejects an absolute filename; .resolve and .is_relative_to check the resolved boundary. These are useful checks for a trusted practice directory but not a substitute for race-resistant opening of untrusted symlinks.
Predict and explain: Should a filename `../secrets.csv` be joined to the approved root and trusted because it starts as a relative path?
No. Reject the parent traversal component and verify the final resolved path is within the approved base.
Specify encodings, BOM behavior and newlines
File contents start as bytes. When Python reads text, it decodes those bytes with an encoding. Specify encoding='utf-8' rather than relying on the machine's default, and use 'utf-8-sig' when a CSV exporter may include a UTF-8 byte-order mark: that codec removes a leading BOM while also reading normal UTF-8. A wrong encoding may raise UnicodeDecodeError or produce damaged text. For CSV, open(path, newline='', encoding='utf-8-sig') so Python's csv module can handle record newlines correctly, including quoted fields containing line breaks. Do not solve encoding errors by blindly ignoring undecodable bytes.
Text is not raw bytes
Path.read_text(encoding='utf-8') decodes text and returns str. Path.read_bytes() returns bytes. An input failure should be surfaced rather than reported as an empty file.
UTF-8 with a BOM
Some spreadsheets export UTF-8 data starting with a BOM. Using utf-8-sig avoids an unexpected '\ufeffhost' column name in a CSV header.
Line endings are format-specific
Text files can use LF or CRLF. The csv module recommends newline='' when opening files because quoted fields can contain line breaks. Don't manually split CSV into lines and commas.
from io import StringIO
import csv
sample = '\ufeffhost,status\nSW-01,online\n'
with StringIO(sample.lstrip('\ufeff'), newline='') as stream:
reader = csv.DictReader(stream)
print(reader.fieldnames)What can happen if a UTF-8 BOM remains at the beginning of a CSV header named host? Reveal the explanation after predicting.
The actual first column may be named '\ufeffhost' instead of 'host', so a strict required-column validation will fail even though the header looks normal in an editor. Decode with utf-8-sig when reading possible BOM-bearing files.
Read local text with the exact codec and newline policy
The browser challenge asks you to read an actual temporary file, not just manipulate a StringIO string. Open it with an explicit mode, encoding and newline option.
from pathlib import Path
from tempfile import TemporaryDirectory
with TemporaryDirectory() as folder:
path = Path(folder) / 'inventory.csv'
path.write_bytes(b'\xef\xbb\xbfhost,status\nSW-01,online\n')
with open(path, 'r', encoding='utf-8-sig', newline='') as stream:
text = stream.read()
print(text.splitlines()[0])The temporary directory is removed automatically when the block exits. utf-8-sig recognizes the optional BOM, and newline='' lets csv handle record boundaries. Errors such as FileNotFoundError and UnicodeDecodeError are not valid empty inputs.
Predict and explain: Why shouldn't you return an empty string when open() raises FileNotFoundError?
An absent input is a failed collection, not a successfully read empty file. The calling pipeline must be able to tell the difference.
Parse CSV with DictReader and inspect row structure
Real CSV can include commas, quotes and even newlines inside quoted fields. csv.DictReader maps each row to column names, which makes the logic independent of a particular column order. It still needs schema validation: fieldnames can be missing or duplicated, and extra columns appear under a None key by default while short rows get None values. Treat suspicious row shapes as invalid evidence, not values to guess. When exporting with DictWriter, declare the output field order and use writeheader(); a stable schema helps downstream tools and spreadsheets.
Quoted commas aren't extra fields
A row such as 'SW-01,"HQ, East",offline' has three fields. split(',') incorrectly produces four; csv.DictReader preserves the quoted site field.
Header validation matters
Check fieldnames before processing rows. Reject missing or repeated required headers, including accidental duplicates; rearranged columns can be valid when names match.
Malformed rows should be counted
Extra fields, missing fields, blank hostnames and unsupported statuses should be recorded as rejected rows. Keep accepted and rejected counts visible so a small successful report doesn't hide bad input.
import csv
from io import StringIO
csv_text = 'status,host,site\noffline,SW-01,"HQ, East"\n'
rows = list(csv.DictReader(StringIO(csv_text, newline='')))
print(rows[0]['site'])Why does the site print as 'HQ, East' instead of becoming two separate columns? Reveal the explanation after predicting.
The csv parser honors double quotes around the field and treats its comma as content rather than a column delimiter. A simple text.split(',') would not respect that structure.
Normalize and validate before counting anything
Parsing turns bytes into fields, but that isn't the same as proving those fields are usable. Trim whitespace around host and status, normalize known status values, and define what to do with blank, duplicated or unexpected records. The example treats online and offline as known statuses; maintenance, missing and misspelled statuses are not automatically online. For deterministic reporting, sort accepted hosts explicitly. If duplicates are ambiguous, reject or flag them instead of silently preferring the first record. Return structured validation details so a technician can tell clean exports from partially rejected data.
Normalization versus invention
' OFFLINE ' may safely normalize to 'offline'. But 'maintenence' should not be silently corrected to 'online': the export isn't supplying a known status.
Duplicates are operational evidence
If two different statuses claim the same hostname, picking one would invent a health conclusion. Mark the duplicate for review or fail according to a documented policy.
Show accepted and rejected
A report with two accepted rows and three rejected should say so. Prefer named keys such as accepted, rejected and errors over a bare success Boolean.
allowed={'online','offline'}
raw = {'host':' AP-02 ', 'status':' OFFLINE '}
host=raw['host'].strip()
status=raw['status'].strip().lower()
print(host, status, status in allowed)Should an unknown status be counted as online because it isn't the literal string 'offline'? Reveal the explanation after predicting.
No. Unknown is not a synonym for healthy. Validate against explicitly allowed statuses and report unrecognized values separately so you don't understate operational risk.
Count duplicates before deciding which records to accept
The local example uses collections.Counter, which counts occurrences. Without this introduction, the learner sees a new library call in a lengthy script and has to infer its purpose.
from collections import Counter
hosts = ['SW-01', 'AP-02', 'sw-01']
normalized = [host.upper() for host in hosts]
frequency = Counter(normalized)
print(frequency['SW-01'])
print(sorted(host for host, count in frequency.items() if count == 1))Counter is a standard-library dictionary-like class. It reports the number of occurrences of each key. Count normalized hostnames first, then exclude all conflicting occurrences rather than accepting an arbitrary one.
Predict and explain: Why is accepting the first SW-01 row and ignoring a later conflicting sw-01 row unsafe?
It invents a trusted version of a conflict. Record or reject the duplicate and avoid claiming either row is authoritative.
Decode and emit predictable JSON
JSON stores objects, arrays, strings, numbers, booleans and null. json.loads() can parse valid JSON text, but a valid JSON document may still have the wrong top-level shape; check that the expected object and collection keys are present. Use json.dumps(report, sort_keys=True, indent=2, ensure_ascii=False) for readable, deterministic output when field ordering and Unicode are important to your project. `ensure_ascii=False` retains Unicode characters in the text, so write the result with UTF-8. Treat NaN and Infinity as invalid reporting numbers rather than relying on Python's permissive default output; allow_nan=False prevents those nonstandard values from being emitted.
Syntax first, schema second
json.loads('[]') succeeds, but a pipeline expecting an object with a devices array should reject that shape. Missing required keys are not valid zero values.
Stable output
Sort keys, normalize rows, and choose deterministic ordering for lists. This makes different runs easier to compare and keeps diff noise out of troubleshooting reports.
Unicode needs deliberate handling
json.dumps(...,ensure_ascii=False) retains characters such as São Paulo in output; file writes should specify encoding='utf-8' to match.
import json
report={'site':'São Paulo','offline':1,'total':2}
print(json.dumps(report,sort_keys=True,ensure_ascii=False))Why doesn't a successful json.loads() call prove that data is safe to pass into a report builder? Reveal the explanation after predicting.
It confirms only JSON syntax. A list, integer or string is valid JSON but may violate the expected object schema. Validate type and required fields before deriving any counts.
Bound file size, row counts and resource use
A file that looks like a harmless export can be unexpectedly large or malformed. Before reading an entire file, apply a sensible byte limit for your tool and require a known source. Avoid calling read_text() on unknown multi-gigabyte exports, and remember size checks are not race-proof on files that other processes can alter. A first small utility can read bounded inputs entirely; later large-scale pipelines can iterate rows instead of building a huge list. Design errors so a too-large or unreadable export is rejected, not reported as a clean inventory with zero devices.
Whole-file versus streaming
Path.read_text() is simple for small verified inputs, but loads the whole decoded file. A streaming loop or csv.DictReader over a file handle consumes records incrementally.
Limits should be intentional
Document a maximum input size and a maximum number of accepted rows, then reject oversized inputs. Python's file size may change between inspection and opening, so additional checks may be required in adversarial environments.
Catch expected failures
FileNotFoundError, UnicodeDecodeError and PermissionError tell different stories. Don't swallow all exceptions; return a clear error category or let the caller handle it.
from pathlib import Path
path=Path('samples/inventory.csv')
max_bytes=1024*1024
if path.is_file() and path.stat().st_size<=max_bytes:
print('Size is acceptable to inspect')
else:
print('Missing or outside size limit')Can a size check alone guarantee that a file stays below the limit while another process is changing it? Reveal the explanation after predicting.
No. Between stat() and read(), another process may change the content. Size limits are useful for expected local data, but strict bounds need to be enforced during reading too in untrusted environments.
Stage output, verify it, then replace deliberately
Writing directly over last week's valid report can leave a half-written file if your process stops. A safer local pattern is to build the complete report in memory, write a temporary file in the destination directory, close it, then use os.replace(temp, destination) to replace the old report. On supported local filesystems, replacement is atomic at the filename level; it is not a guarantee of crash durability or universal network-share behavior. os.replace overwrites the destination, so require an approved output location and explicit overwrite policy. Clean up temporary artifacts on failure and keep your original input export unchanged.
Temporary file in the same directory
tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', dir=destination.parent, delete=False) places staging near the target so replacement usually stays on the same filesystem.
Validate before publishing
Build and validate the entire JSON report first. If parsing or validation fails, do not touch the existing output. Verify report structure before attempting an overwrite.
The real boundary
A browser exercise writes only in Pyodide's isolated virtual filesystem. A local script can write actual files, so always inspect the chosen directory and protect valuable outputs.
import json
payload={'site':'HQ','total':2}
text=json.dumps(payload,sort_keys=True,ensure_ascii=False,allow_nan=False)
print(text)Why is writing a staging file preferable to immediately opening an existing report with 'w'? Reveal the explanation after predicting.
Opening the final report with 'w' truncates it immediately. Writing and validating a separate candidate first helps preserve the previous report if processing fails before replacement. Replacement still needs a deliberate overwrite policy.
Stage, replace and clean up a report step by step
The staged-write challenge requires a temporary file, explicit overwrite policy and cleanup. This example shows the precise Python APIs first; the later exercise makes you add validation and rejection paths independently.
import os
from pathlib import Path
from tempfile import TemporaryDirectory, NamedTemporaryFile
with TemporaryDirectory() as folder:
destination = Path(folder) / 'report.json'
temporary = None
try:
with NamedTemporaryFile('w', encoding='utf-8',
dir=folder, delete=False) as stream:
temporary = Path(stream.name)
stream.write('{"total": 2}\n')
if destination.exists():
raise FileExistsError('overwrite not approved')
os.replace(temporary, destination)
temporary = None
print(destination.read_text().strip())
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)Create a candidate in the same directory, close it before replacing the destination, and remove an abandoned candidate in finally. os.replace overwrites an existing target, so real code must require deliberate overwrite approval. This example stays in a disposable directory.
Predict and explain: If JSON serialization fails, should the existing report be opened with mode 'w' before reporting the error?
No. Build and validate the complete candidate first. Opening the existing report with 'w' would truncate it immediately.
Turn a CLI into a repeatable inventory file workflow
The Applied Python project can now accept --input and --output arguments, find a known local inventory CSV, validate required host and status fields, and produce a deterministic JSON report. Keep parsing and transformations as pure functions where possible; let main(argv) coordinate file operations and return an exit code. Use --dry-run or a preview default to inspect the proposed report without writing. A useful runbook says which files may be touched, what a successful report contains, and what the tool does when an input is absent or malformed. Test missing headers, quoted commas, duplicate hosts, empty inventories, and an existing output report before calling the workflow reliable.
Your CLI remains the entry point
The argparse contract from Lesson 02 supplies input and output paths. The pipeline functions do the parsing and validation without needing to know about command-line options.
Report status must be observable
A valid empty export may produce a report with total 0. Missing columns or unreadable input must produce an error and a nonzero exit code without replacing a previously valid report.
Read-only preview is the default learning mode
Use fictional local files and inspect the JSON result in memory before adding an explicit --write switch. Never aim a tutorial script at a live share or sensitive export.
def report_from_rows(rows):
accepted = [r for r in rows if r['status']=='offline']
return {'total':len(rows),'offline_hosts':sorted(r['host'] for r in accepted)}
print(report_from_rows([{'host':'AP-02','status':'offline'}]))What should happen when a report already exists but the new CSV source has no required host column? Reveal the explanation after predicting.
The pipeline should reject the invalid schema, report the reason, return a nonzero status and preserve the existing output. A malformed source is not a valid empty inventory and must not overwrite reliable evidence.
Run a guarded CSV-to-JSON project locally
Build on the local northstar-python-tool project from Lesson 01. This example uses only the standard library and deliberately reads a fictional CSV in samples/. It previews a JSON report by default, leaving files untouched. An explicit --write saves a report inside reports/; a separate --replace is required if that output already exists.
Prepare a fictional sample
Inside the project root, manually create a folder samples and save inventory.csv there using UTF-8 encoding. The data below intentionally includes a duplicate hostname, an unknown status and a quoted comma. Create a separate empty reports folder for the output.
host,status,site
SW-01,online,HQ
AP-02,offline,BR1
AP-02,online,BR1
FW-01,offline,"HQ, East"
BAD,maintenance,HQ
Know the safe boundaries
The local script reads only from the project's samples folder and writes only inside reports. It rejects absolute filenames and traversal segments, limits input to 256 KiB, handles BOM-bearing UTF-8 exports, and rejects missing CSV columns before publication. These checks are suitable for a trusted learning workspace, not a complete defense against adversarial symlink races.
Save the following complete Python source as inventory_pipeline.py in your project root. Read the main() function and report logic before copying it; the code illustrates the intended design rather than replacing the independent challenges in Practice.
import argparse
import csv
import json
import os
import sys
from collections import Counter
from io import StringIO
from pathlib import Path
from tempfile import NamedTemporaryFile
ROOT = Path(__file__).resolve().parent
MAX_BYTES = 256 * 1024
def within(folder, filename):
name = Path(filename)
base = (ROOT / folder).resolve()
if not filename.strip() or name.is_absolute() or ".." in name.parts:
raise ValueError("Provide a relative filename inside " + folder)
target = (base / name).resolve()
if target == base or not target.is_relative_to(base):
raise ValueError("Path leaves the allowed " + folder + " directory")
return target
def read_csv(path):
with path.open("rb") as stream:
raw = stream.read(MAX_BYTES + 1)
if len(raw) > MAX_BYTES:
raise ValueError("Input exceeds 256 KiB")
return raw.decode("utf-8-sig")
def analyze(text):
reader = csv.DictReader(StringIO(text, newline=""), strict=True)
fields = reader.fieldnames or []
if len(fields) != len(set(fields)) or not {"host","status"} <= set(fields):
raise ValueError("Missing or repeated host/status columns")
possible = []
rejected = 0
for row in reader:
host, status = row.get("host"), row.get("status")
if (None in row or any(value is None for value in row.values())
or not isinstance(host, str) or not host.strip()
or not isinstance(status, str)
or status.strip().lower() not in {"online","offline"}):
rejected += 1
continue
possible.append((host.strip().upper(), status.strip().lower()))
counts = Counter(host for host, status in possible)
accepted = [(host, status) for host, status in possible if counts[host] == 1]
rejected += len(possible) - len(accepted)
return {
"accepted": len(accepted),
"rejected": rejected,
"offline_hosts": sorted(host for host, status in accepted if status == "offline")
}
def write_staged(path, report, replace=False):
if not path.parent.is_dir():
raise ValueError("Output directory does not exist")
if path.exists() and not replace:
raise ValueError("Output exists; use --replace to approve overwrite")
text = json.dumps(report, sort_keys=True, ensure_ascii=False, allow_nan=False, indent=2) + "\n"
temporary = None
try:
with NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent,
prefix=".inventory-", suffix=".tmp", delete=False) as stream:
temporary = Path(stream.name)
stream.write(text)
os.replace(temporary, path)
temporary = None
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
def main(argv=None):
parser = argparse.ArgumentParser(description="Fictional, offline inventory file pipeline")
parser.add_argument("--input", default="inventory.csv", help="Filename within samples/")
parser.add_argument("--output", default="summary.json", help="Filename within reports/")
parser.add_argument("--write", action="store_true", help="Publish report to local reports/")
parser.add_argument("--replace", action="store_true", help="Allow replacing an existing output")
args = parser.parse_args(argv)
if args.replace and not args.write:
parser.error("--replace requires --write")
try:
source = within("samples", args.input)
destination = within("reports", args.output)
report = analyze(read_csv(source))
if args.write:
write_staged(destination, report, replace=args.replace)
print("Saved report to " + str(destination))
else:
print(json.dumps(report, sort_keys=True, ensure_ascii=False, indent=2))
return 0
except (ValueError, OSError, UnicodeError, csv.Error) as exc:
print("Pipeline error: " + str(exc), file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())Start with a read-only preview
python inventory_pipeline.pypython inventory_pipeline.py --helppython inventory_pipeline.py --writepython inventory_pipeline.py --write --replaceRun these inside the activated virtual environment, or invoke its Python executable directly. Review the preview first and run the write command only against your own disposable practice folder.
Verify the result and failure behavior
The sample contains two accepted devices, three rejected records (both AP-02 rows plus the unsupported status), and one offline accepted host: FW-01. Before using --replace, deliberately break the CSV header and confirm the script reports an error and leaves any existing report unchanged. This is the behavior we want in real automation.
Write the pipeline logic yourself
The first exercise checks pathname boundaries. The full coding clinic then explores actual UTF-8 file reads, malformed CSV rows, duplicate hosts, JSON schema checks, deterministic reports and staged writes. The last task makes you design an end-to-end inventory analyzer tested against unfamiliar data.
Write it. Run it. Understand it.
Work with pathlib, real temporary Python files, CSV/JSON schema validation and staged reports inside the isolated browser filesystem.
Resolve a file inside an approved folder
Write safe_file(root, filename) using pathlib.Path. Return a resolved Path only when filename is a relative file path inside root, has no '..' path component, isn't the root directory itself, and resolves inside the root. Otherwise return None. For root '/northstar/incoming' and 'inventory.csv', print the complete resolved path. The grader changes folder names and tries traversal, absolute and sibling-prefix paths. Don't create or delete any files.
/northstar/incoming/inventory.csvTry solving it before asking for help. Hints never show a completed program.
Your printed output and errors will appear here.
Run the code, then step through the executed lines to watch variable values change.
Know why the output is trustworthy.
A working pipeline should explain which rows were accepted, which were rejected and why publishing is safe. Try the exercises with changed input before treating a passing sample as proof of reliability.
Continue to all eight file-pipeline challenges →Next: REST APIs & Authentication →Applied Python path →Official references
The official Python documentation is the source of truth for path methods, CSV parsing, JSON serialization and temporary-file behavior. Some filesystem guarantees vary across platforms; read the caveats before adapting local scripts for operational use.
