DEV Community

Alex Chen
Alex Chen

Posted on

Why This Quoted CSV Row Fails: A Python Experiment

The Killam library was doing that fluorescent hum that means you have fifteen minutes. My participation CSV parser had just printed ['Chen', 'Alex', '90'], so I snapped the laptop shut like the lab was finished.

It wasn't.

Next morning the TA dropped one quoted comma into the same fixture. Same function. Same little victory grin from me. Then a list that looked like a sandwich someone sat on. Have you ever trusted a sample that was too polite to fight back?

That night is this case study. Not a CSV tutorial. One question, kept small on purpose: if a generated script passes the row you showed the model, what single spec flip would prove you never owned the behavior?

I am calling the move a mutation gate. You keep the original fixture. You change exactly one rule. You run the same function again. If it dies, good. That death is the lesson. If it lives, you still have not proven much. You only proved the function survived one insult.

Background, in the ugly version

I needed a splitter for a tiny attendance file. Names, one grade, nothing fancy. I asked a free model for "a Python function that splits CSV rows" and pasted the friendliest row I could invent. It split on commas. It stripped spaces. It even had a docstring that sounded like it had paid rent.

The sample was Chen,Alex,90. Of course it passed. A comma-split on a row with no quotes is like testing a boat on a carpet. Why would it leak?

The lab file, because labs are not interior decorating, contained "Chen, Alex",90. The naive split produced four fields. My grade average function then tried to add ' Alex"' to a float and fell over. I did not fail because Python is hard. I failed because I had never watched the spec move.

Spec drift, in this notebook, just means the written rule and the next row disagree in one place. Quoted commas. Empty fields. A different delimiter. Students meet it as "the TA is being annoying." The annoying part is the actual curriculum.

Goal

I wanted a gate I could run in under a second, with no packages, that made the failure visible before I called the script "done." The gate had to print the original result and the mutated result side by side. I also wanted one fixture that was supposed to break, so I could not accidentally celebrate a quiet pass.

Prerequisites are boring and I still skip them when I am tired, which is how I ship carpet-boats. You need Python 3.11 or newer and the standard library. Nothing else. I ran the file below as python3 mutation_gate.py on 3.11. I did not pin a patch version because the script uses only json, copy, and typing.

The tiny project

The function under test is deliberately naive. That is the point. A model will happily emit raw.split(delimiter) and a strip loop. The gate then clones the spec, flips quoted from False to True, and asks the same function to parse a row that is legal under the new rule.

Read the fixtures before the code, the way you would read a lab handout. ROW_PLAIN is the carpet. ROW_QUOTED is the water. Predict, out loud, which call returns four strings. If you cannot predict it, you are in the right article.

#!/usr/bin/env python3
"""mutation_gate.py — pass the polite row, then watch the quoted comma."""
from __future__ import annotations

import copy
import json
from typing import Any, Callable

ROW_PLAIN = "Chen,Alex,90"
ROW_QUOTED = '"Chen, Alex",90'

BASE_SPEC: dict[str, Any] = {
    "delimiter": ",",
    "quoted": False,
    "strip": True,
}


def naive_split(raw: str, spec: dict[str, Any]) -> list[str]:
    """The splitter I shipped after the sample printed nicely."""
    parts = raw.split(spec["delimiter"])
    if spec.get("strip"):
        parts = [p.strip() for p in parts]
    return parts


def quoted_split(raw: str, spec: dict[str, Any]) -> list[str]:
    """Tiny state machine: commas inside quotes are not delimiters."""
    delim = spec["delimiter"]
    out: list[str] = []
    buf: list[str] = []
    in_quotes = False
    i = 0
    while i < len(raw):
        ch = raw[i]
        if ch == '"':
            in_quotes = not in_quotes
            i += 1
            continue
        if ch == delim and not in_quotes:
            token = "".join(buf)
            out.append(token.strip() if spec.get("strip") else token)
            buf = []
            i += 1
            continue
        buf.append(ch)
        i += 1
    token = "".join(buf)
    out.append(token.strip() if spec.get("strip") else token)
    return out


def flip_quoted(spec: dict[str, Any]) -> dict[str, Any]:
    mutant = copy.deepcopy(spec)
    mutant["quoted"] = not bool(mutant.get("quoted"))
    return mutant


def run_gate(
    fn: Callable[[str, dict[str, Any]], list[str]],
    spec: dict[str, Any],
    row: str,
    mutant_row: str,
) -> dict[str, Any]:
    original = fn(row, spec)
    mutant_spec = flip_quoted(spec)
    # The mutated spec says quotes matter. Feed it a row that uses them.
    mutated = fn(mutant_row, mutant_spec)
    expected_mutant = ["Chen, Alex", "90"]
    return {
        "function": fn.__name__,
        "original": original,
        "mutated": mutated,
        "mutant_ok": mutated == expected_mutant,
        "len_original": len(original),
        "len_mutated": len(mutated),
    }


def main() -> None:
    reports = [
        run_gate(naive_split, BASE_SPEC, ROW_PLAIN, ROW_QUOTED),
        run_gate(quoted_split, BASE_SPEC, ROW_PLAIN, ROW_QUOTED),
    ]
    print(json.dumps(reports, indent=2))
    naive_ok = reports[0]["mutant_ok"]
    quoted_ok = reports[1]["mutant_ok"]
    if naive_ok:
        raise SystemExit("unexpected: naive_split survived the quoted comma")
    if not quoted_ok:
        raise SystemExit("quoted_split should own the flipped spec")
    print("gate: naive failed the flip (good); quoted_split held (also good)")


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Save that as mutation_gate.py. Then run it from the same directory.

python3 mutation_gate.py
Enter fullscreen mode Exit fullscreen mode

Expected output, including the JSON the gate prints before the one-line verdict:

[
  {
    "function": "naive_split",
    "original": ["Chen", "Alex", "90"],
    "mutated": ["\"Chen", "Alex\"", "90"],
    "mutant_ok": false,
    "len_original": 3,
    "len_mutated": 3
  },
  {
    "function": "quoted_split",
    "original": ["Chen", "Alex", "90"],
    "mutated": ["Chen, Alex", "90"],
    "mutant_ok": true,
    "len_original": 3,
    "len_mutated": 2
  }
]
gate: naive failed the flip (good); quoted_split held (also good)
Enter fullscreen mode Exit fullscreen mode

Look at naive_split on the mutated row. The length stayed 3, which is a nasty coincidence. I almost used len == 3 as my success check, and that check would have lied. The quoted name got shredded into "Chen and Alex", and a length test would have clapped. Have you ever graded a function by counting slots instead of reading the slots?

That is why the gate compares against ['Chen, Alex', '90'], not against a field count. A mutation that only checks shape is still a carpet.

The error input I wanted to see

Change ROW_QUOTED to 'Chen,,90' and keep quoted as the flipped flag. Now you have two different insults fighting. The naive split returns ['Chen', '', '90']. My first draft treated the empty field as a name and wrote a thank-you email to nobody. The quoted splitter does not magically know what an empty field means either. It only knows commas inside quotes.

If you want a clean failing fixture, keep ROW_QUOTED as written and temporarily make quoted_split call naive_split. The process should exit non-zero. That is the alarm. A gate that cannot fail is a blog post.

Where the free tools actually sat

I drafted the naive splitter the way a lot of us draft lab glue now: I described the polite row to a model and pasted the function it gave me. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I used MonkeyCode's free model access for that first draft, then ran mutation_gate.py on their free server option so I could rerun the flip between classes without parking a laptop in a lab that closes at ten.

The model was fine at emitting split. It was not fine at inventing the insult I had not typed. That is not a dunk on the tool. That is the whole shape of the homework. Free model access is useful when you want a candidate function fast. The free server option is useful when the interesting part is rerunning a 40-line gate, not configuring a GPU. Neither one owns the spec for you.

Results

On the polite row, both functions agreed. That is the trap. Agreement on the sample is a social event, not a proof. After the flip, naive_split still returned three strings and still failed the value check. quoted_split returned two strings and matched the expected name. I had to stare at the JSON twice because the naive length looked "reasonable."

What changed in my notes was not the parser. It was the definition of done. Done used to mean "it printed something list-shaped." Done now means "I can name the spec field I have not flipped yet." Quoted commas were field one. Empty fields are still sitting there, tapping a pen.

Common mistakes I actually made

I mutated the spec in place the first time and then could not explain why the original run started failing. copy.deepcopy exists because students are optimistic. I also stripped quotes as a post-pass, part.strip('"'), which turns "Chen, Alex" into a single field only if you already split correctly. If you split first, stripping quotes is perfume on a broken list.

The third mistake was asking the model to "make it more robust" without showing ROW_QUOTED. Robust, with no insult attached, just means extra branches for the carpet.

What you should understand after this

A sample is a compliment. A mutation is a question. If you cannot point to the one field you flipped, you are still demoing. Generated code is especially good at demoing, because the model will optimize for the row in the prompt the same way I optimize for the example on the slide.

You should also understand that length checks are not value checks. Three smashed pieces can still be three. And you should understand that a quote-aware splitter is still not CSV. Newlines inside quotes, doubled quotes, and mixed encodings will all walk through this gate like it is a velvet rope.

Who should not use this approach? Anyone shipping billing files, anyone who needs RFC 4180, and anyone hoping a free server option is a production SLA. Do not use it to hide from a hidden test suite either. If the TA already has holdout rows, this gate is practice, not a cheat sheet. Skip it if you do not have five minutes to read the JSON. The value is in the mismatch, not in collecting green checkmarks.

One extension, then I will stop talking

Add a second flip that sets delimiter to ';' and feeds 'Chen;Alex;90'. Leave quoted_split unchanged. Will it pass, or did you hard-code a comma in your head while the spec dictionary sat there unused? Predict before you run it. If you get a counterexample smaller than mine, I want that row. The polite sample already lied to me once. I am trying not to let it lie twice.

If you want a scratch place to rerun this exact gate between lectures, MonkeyCode's free server option is how I kept the experiment off a dying laptop battery. The file is the curriculum. The server is just where the file happened to run.

Top comments (0)