Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Iteration & Laziness  ›  Capstone Project

Capstone — The Feed

40 minChunks, lines, records and batches chained into one lazy pipeline that streams any size of file and stops reading when it has enough
You're building a piece ofQuarry — the feed
This piece — stream_records(): Chunks, lines, records and batches in one lazy pipeline: a whole file streamed in constant memory.
Scenario The pump sensors write a CSV of readings all day, broken rows included, and by midnight it's bigger than the memory Quarry has to read it with.
Your task
Build Quarry's feed. chunks(text, size, tally) and lines_from_chunks(chunks) are provided. Write records(lines, tally), which skips blanks and the header and yields (sensor, reading) while counting bad rows in tally["skipped"]; batched(items, n), which yields a fresh list the moment it holds n items and the leftovers at the end; and stream_records(text, chunk_size, batch_size, max_batches), which chains them, stops after max_batches with islice (None = no limit), and returns {"batches": [[count, total], ...], "skipped": n, "chunks_read": n}. Example: the eight-line pump file with chunks of 8, batches of 2 and max_batches 1 → {"batches": [[2, 10.0]], "skipped": 1, "chunks_read": 7}.

Capstone — The Feed

In short: the pump sensors report every second, all day, into one CSV file — by midnight, hundreds of megabytes of sensor,reading rows, some broken. This capstone chains lesson 01's protocol, lesson 02's chunk reader, and lesson 03's lazy stages into the feed: chunks → lines → records → batches → a limit. At any moment it holds one chunk, one carried partial line, one record and one unfinished batch — nothing else. Everything upstream is still on disk; everything downstream has already been handed over. The rule it has to keep: file size doesn't matter, and asking for only the first few batches means the rest is never read.

Five stages — chunks, lines_from_chunks, records, batched, islice — chained left to right, with a tally dict below receiving chunks_read and skipped counts from the stages that know them

The shape of the feed

Four stages, and a limit on the end:

Drawing diagram…

At any moment the feed holds one chunk, one carried partial line, one record and one unfinished batch. Nothing else. Everything upstream is still on disk, and everything downstream has already been handed over.

Judge each row without taking the whole feed down

records(lines, tally) does three jobs: skip blank lines silently, skip the first non-blank line (the header), and turn each remaining line into (sensor, reading) — or count it as bad. A row is bad when it doesn't have exactly two fields, or float() refuses the reading. The naive version trusts the file, and the first broken row takes the whole feed down with it:

broken — fix itValueError

Run it and read which row killed it. Then make records skip and count both kinds of bad row instead of raising.

One bad row in ten million shouldn't stop a nightly run — it should show up as a count someone can glance at in the morning. That's what tally is for: a plain dict the stages write their counts into, since a generator can only yield one kind of thing, and a count about the stream has to travel on the side rather than mixed into it.

Batch pump readings without a stale reference bug

batched(items, n) collects items into lists of n, yields each list the moment it's full, and flushes the leftovers at the end:

What does this print?
def batched(items, n):
    batch = []
    for item in items:
        batch.append(item)
        if len(batch) == n:
            yield batch
            batch = []
    if batch:
        yield batch

print(list(batched(range(7), 3)))

One line looks like it could be tidier: batch = [] makes a new list every time. It's tempting to write batch.clear() and reuse one list instead — this is the real mistake, and it doesn't raise anything, it just quietly corrupts the output:

What does this print?
def batched(items, n):
    batch = []
    for item in items:
        batch.append(item)
        if len(batch) == n:
            yield batch
            batch.clear()

print(list(batched(range(4), 2)))

This is the one real hazard of a generator's parked frame: it still holds references to whatever it yielded, and if it mutates them after the yield, the consumer sees the change. (Python 3.12's itertools.batched yields tuples and never has this problem — writing it yourself once is how you learn to spot the bug.)

Prove the feed actually stops early

The last stage is islice(batches, max_batches) — lesson 03's take, from itertools. islice(x, None) means no limit, so one line serves both modes. When islice has its last batch it stops asking, batched stays parked, records stays parked, and the next chunk is never read. chunks_read in the tally is the proof — step through a small run with a limit of one batch and watch the count stop short of the end of the text:

step through it
1from itertools import islice
2 
3tally = {"chunks_read": 0}
4 
5def chunks(text, size):
6 for start in range(0, len(text), size):
7 tally["chunks_read"] += 1
8 yield text[start:start + size]
9 
10def lines_from_chunks(chunks):
11 carry = ""
12 for chunk in chunks:
13 carry += chunk
14 *done, carry = carry.split("\n")
15 for line in done:
16 yield line
17 if carry:
18 yield carry
19 
20text = "h\na,1\nb,2\nc,3\nd,4\n"
21lines = lines_from_chunks(chunks(text, 4))
22first_two = list(islice(lines, 3))
23print(first_two, tally["chunks_read"], "of", -(-len(text) // 4))

Three lines were enough, so three chunks of five were read, not all five. The third line, b,2, only became complete in the third chunk — on the real file that's the difference between reading a few kilobytes and reading all of it. Double the rows in the file and an eager version (read the whole thing into a string, parse every row into a list) doubles its memory; the lazy feed, holding one chunk and one batch at a time, doesn't move.

What you're given, and what you write

The starter already contains chunks(text, size, tally) (reads size characters at a time, counting tally["chunks_read"]) and lines_from_chunks(chunks) (lesson 02's line feed). You write three things:

  1. records(lines, tally) — skip blanks, skip the header, yield (sensor, reading) as a float, count every bad row in tally["skipped"].
  2. batched(items, n) — lists of n, each yielded the moment it's full, a fresh list every time, leftovers at the end.
  3. stream_records(text, chunk_size, batch_size, max_batches) — chain the stages, stop with islice, summarise each batch as [count, total rounded to 2 places], and return {"batches": [...], "skipped": n, "chunks_read": n}.

Because the stages are lazy, skipped only counts the bad rows the feed actually reached — rows it never read were never judged.


🎯 Your turn

With the text "sensor,reading\npump-1,4.5\npump-2,oops\npump-1,5.5\n\npump-3,1.0\npump-1\npump-2,2.0\n", chunks of 8 and batches of 2:

  • max_batches = None → {"batches": [[2, 10.0], [2, 3.0]], "skipped": 2, "chunks_read": 10}
  • max_batches = 1 → {"batches": [[2, 10.0]], "skipped": 1, "chunks_read": 7}, which stops on the chunk that completes pump-1,5.5 and never reaches the rest.

Hint: yield a batch straight after the append that fills it, not at the top of the next pass. That way the feed never pulls a record it doesn't need.

Press ▶ Run, then ✓ Check. ✅

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upRead it a piece at a time

Write the generator chunks(text, size), which yields text in pieces of size characters, the last one possibly shorter. It stands in for calling f.read(size) until the file is used up. chunk_list(text, size) returns the list.

generator-pipelineschunked-reading
DrillJudge every row, crash on none

Write the generator records(lines, tally): skip blank lines, skip the header (the first non-blank line), yield (sensor, reading) with the sensor stripped and the reading a float, and add 1 to tally["skipped"] for every row without exactly two fields or with a reading float() refuses. parse_report(lines) (already written) collects the result.

generator-pipelinessplit
BuildBatches that don't change after delivery

This batched(items, n) uses batch.clear() to reuse one list, and every batch comes out wrong. Fix it so each batch is a list that never changes after it has been yielded. batches(items, n) returns them all.

generator-pipelinesbatching
BossA feed you can read twice

A generator is single-use, but Quarry's report wants to walk the same file twice. Write ReadingFeed(text, size), an iterable whose __iter__ is a generator function (it contains yield). Each loop starts a fresh pass: chunk the text, rebuild lines with lines_from_chunks, skip blanks and the header, and yield each good reading as a float, skipping bad rows. feed_twice sums it twice.

custom-iterablesgenerator-pipelines
CapstoneRaise the alarm and stop reading

The night watch only needs the first reading above a threshold, and then it should stop reading the file. chunks, lines_from_chunks and records are provided. Write early_alarm(text, size, threshold), which returns {"first": [sensor, reading] or None, "chunks_read": n}.

generator-pipelineslazy-evaluationchunked-reading
ExplainJust read the whole file?

A teammate reviewing the feed says: "This is over-engineered. Just do open(path).read().splitlines() and loop over the list. Memory is cheap." Reply to them. Say when they're right, when the streaming feed matters, and what the streaming version costs you in return.

stream_records.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
stream_records(text, chunk_size, batch_size, max_batches) → dictStream the file through chunks, lines, records and batches, and report the batch summaries, the bad rows and how much was read.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.