Case Study: Parsing Real-World Text Data — Dr. Patel's FASTA File Processor
The Scenario
Dr. Anika Patel is a molecular biologist at the University of Michigan. Her research focuses on identifying genetic markers associated with antibiotic resistance in bacteria. Every week, she receives sequencing results from the campus genomics core facility — sometimes hundreds of DNA sequences at a time.
The sequences arrive in FASTA format, one of the most widely used text formats in bioinformatics. A FASTA file is plain text with a simple structure:
>sequence_id description text
ATCGATCGATCGATCG
GCTAGCTAGCTAGCTA
>next_sequence_id more description
AAAGGGCCCTTTTAAA
Each sequence starts with a header line beginning with >, followed by one or more lines of nucleotide characters (A, T, C, G). That's it. No XML, no JSON, no binary encoding — just text. And that means the entire processing pipeline is fundamentally string processing.
Dr. Patel's problem: she needs to extract specific information from these files — sequence names, lengths, nucleotide compositions, and GC content — and she needs to do it reliably for files containing anywhere from 10 to 10,000 sequences.
The Manual Approach (Before Python)
Before learning Python, Dr. Patel used a combination of Excel and a web-based tool called BLAST to process her data. The workflow looked like this:
- Open the FASTA file in a text editor
- Manually count sequences (count the
>lines) - Copy individual sequences into a web form for analysis
- Record results in a spreadsheet
- Repeat for each sequence
For a file with 50 sequences, this took about three hours. For 500 sequences, it was simply not feasible by hand.
Building the Parser: Step by Step
Step 1: Reading the File Structure
The first challenge is understanding what each line means. In FASTA, lines serve different roles:
def classify_lines(fasta_text):
"""Classify each line in FASTA text as header, sequence, or empty."""
for line_num, line in enumerate(fasta_text.splitlines(), start=1):
stripped = line.strip()
if not stripped:
line_type = "EMPTY"
elif stripped.startswith(">"):
line_type = "HEADER"
else:
line_type = "SEQUENCE"
print(f" Line {line_num:3d}: [{line_type:8s}] {stripped[:50]}")
sample = """>gi|12345|ref|NM_001.2| BRCA1 gene, Homo sapiens
ATCGATCGATCGATCGATCG
GCTAGCTAGCTAGCTAGCTA
>gi|67890|ref|NM_002.1| TP53 tumor suppressor
TTTTAAAACCCCGGGGAAAA
CCCCTTTTGGGGAAAA"""
classify_lines(sample)
Output:
Line 1: [HEADER ] >gi|12345|ref|NM_001.2| BRCA1 gene, Homo sapiens
Line 2: [SEQUENCE] ATCGATCGATCGATCGATCG
Line 3: [SEQUENCE] GCTAGCTAGCTAGCTAGCTA
Line 4: [EMPTY ]
Line 5: [HEADER ] >gi|67890|ref|NM_002.1| TP53 tumor suppressor
Line 6: [SEQUENCE] TTTTAAAACCCCGGGGAAAA
Line 7: [SEQUENCE] CCCCTTTTGGGGAAAA
The string methods at work here: strip() to handle whitespace, startswith(">") to identify headers, and splitlines() to iterate line by line.
Step 2: Parsing Headers
FASTA headers encode structured information, but the format varies between databases. The NCBI format uses pipe-separated fields:
>gi|12345|ref|NM_001.2| BRCA1 gene, Homo sapiens
Dr. Patel needs to extract the accession number and gene name:
def parse_ncbi_header(header_line):
"""Parse an NCBI-format FASTA header into components."""
# Remove the leading '>'
header = header_line.lstrip(">").strip()
# Split on '|' to get fields
parts = header.split("|")
result = {
"raw": header,
"gi_number": None,
"database": None,
"accession": None,
"description": None,
}
if len(parts) >= 4:
result["gi_number"] = parts[1].strip()
result["database"] = parts[2].strip()
# The accession and description are in the last field
last_part = parts[-1].strip()
# Split on first space to separate accession from description
space_pos = last_part.find(" ")
if space_pos != -1:
result["accession"] = parts[3].strip()
result["description"] = last_part[space_pos:].strip()
else:
result["accession"] = last_part
return result
# Test
header = ">gi|12345|ref|NM_001.2| BRCA1 gene, Homo sapiens"
info = parse_ncbi_header(header)
for key, value in info.items():
print(f" {key:>15s}: {value}")
Output:
raw: gi|12345|ref|NM_001.2| BRCA1 gene, Homo sapiens
gi_number: 12345
database: ref
accession: NM_001.2
description: BRCA1 gene, Homo sapiens
Key string techniques: lstrip(">") to remove the header marker, split("|") to break the pipe-delimited fields, find(" ") to locate the boundary between accession and description, and slicing to extract substrings.
Step 3: Computing Sequence Statistics
Now Dr. Patel needs to calculate metrics for each sequence — length, nucleotide counts, and GC content:
def sequence_stats(sequence):
"""Calculate statistics for a DNA sequence string."""
# Clean the sequence: remove whitespace, convert to uppercase
clean = sequence.replace("\n", "").replace(" ", "").upper()
length = len(clean)
if length == 0:
return None
# Count each nucleotide
counts = {}
for nucleotide in "ATCG":
counts[nucleotide] = clean.count(nucleotide)
# GC content: percentage of G + C
gc = (counts["G"] + counts["C"]) / length * 100
# Check for unexpected characters
known = sum(counts.values())
unknown = length - known
return {
"length": length,
"counts": counts,
"gc_content": gc,
"unknown_chars": unknown,
}
# Test
seq = "ATCGATCGATCGATCG\nGCTAGCTAGCTAGCTA"
stats = sequence_stats(seq)
print(f" Length: {stats['length']}")
print(f" Nucleotides: {stats['counts']}")
print(f" GC content: {stats['gc_content']:.1f}%")
print(f" Unknown: {stats['unknown_chars']}")
Output:
Length: 36
Nucleotides: {'A': 9, 'T': 9, 'C': 9, 'G': 9}
GC content: 50.0%
Unknown: 0
Step 4: The Complete Parser
Putting it all together — a function that reads a FASTA text and returns structured data for every sequence:
def parse_fasta(fasta_text):
"""Parse FASTA-formatted text into a list of sequence records.
Each record is a dict with 'header', 'sequence', and 'stats' keys.
"""
records = []
current_header = None
current_seq_lines = []
for line in fasta_text.splitlines():
stripped = line.strip()
if not stripped:
continue # skip blank lines
if stripped.startswith(">"):
# Save the previous record (if any)
if current_header is not None:
full_seq = "".join(current_seq_lines)
records.append({
"header": parse_ncbi_header(current_header),
"sequence": full_seq,
"stats": sequence_stats(full_seq),
})
# Start a new record
current_header = stripped
current_seq_lines = []
else:
current_seq_lines.append(stripped)
# Don't forget the last record
if current_header is not None:
full_seq = "".join(current_seq_lines)
records.append({
"header": parse_ncbi_header(current_header),
"sequence": full_seq,
"stats": sequence_stats(full_seq),
})
return records
# Test with sample data
fasta_data = """>gi|12345|ref|NM_001.2| BRCA1 gene
ATCGATCGATCGATCGATCG
GCTAGCTAGCTAGCTAGCTA
>gi|67890|ref|NM_002.1| TP53 gene
TTTTAAAACCCCGGGG
>gi|11111|ref|NM_003.3| MYC oncogene
GGGGCCCCAAAATTTT
GGGGCCCCAAAATTTT"""
records = parse_fasta(fasta_data)
# Display results in a formatted table
print(f"\n {'Gene':<20s} {'Accession':<12s} {'Length':>7s} {'GC%':>6s}")
print(f" {'-'*20} {'-'*12} {'-'*7} {'-'*6}")
for rec in records:
desc = rec["header"]["description"] or "Unknown"
acc = rec["header"]["accession"] or "N/A"
length = rec["stats"]["length"]
gc = rec["stats"]["gc_content"]
print(f" {desc:<20s} {acc:<12s} {length:>7d} {gc:>5.1f}%")
print(f"\n Total sequences: {len(records)}")
Output:
Gene Accession Length GC%
-------------------- ------------ ------- ------
BRCA1 gene NM_001.2 40 50.0%
TP53 gene NM_002.1 16 50.0%
MYC oncogene NM_003.3 32 50.0%
Total sequences: 3
String Techniques Used
This case study demonstrates nearly every string technique from Chapter 7:
| Technique | Where Used | Why |
|---|---|---|
startswith(">") |
Identifying header lines | Pattern matching |
strip() |
Cleaning every input line | Removing whitespace |
lstrip(">") |
Removing header marker | Targeted stripping |
split("\|") |
Parsing pipe-delimited headers | Structured text parsing |
split(" ") |
Separating accession from description | Word boundary detection |
find(" ") |
Locating first space in a field | Position searching |
"".join(lines) |
Concatenating multi-line sequences | Reassembly |
upper() |
Normalizing nucleotide case | Case-insensitive processing |
count("G") |
Counting specific nucleotides | Character frequency |
replace("\n", "") |
Removing newlines from sequences | Cleaning |
len() |
Sequence length | Measurement |
| f-string formatting | Aligned output table | Display |
Discussion Questions
-
Robustness: The parser assumes well-formed FASTA data. What could go wrong with real-world files? Consider: malformed headers, non-standard nucleotide characters (N, R, Y), very long lines, mixed line endings (\n vs \r\n). How would you handle each?
-
Scalability: This parser loads the entire file into memory as a string. For a file with 10 million sequences, this could consume gigabytes of RAM. How might you modify the approach to process one sequence at a time? (Hint: think about reading files line by line in Chapter 10.)
-
Immutability matters: The
sequence_statsfunction callssequence.replace().replace().upper(). Each call creates a new string. For a 3-billion-character human genome sequence, how many copies would this chain create? Is there a more efficient approach? -
Abstraction (Ch 6 callback): We built this parser as several small functions, each doing one job. How does this decomposition relate to the function design principles from Chapter 6? Could you test each function independently?
-
Real-world impact: Dr. Patel's manual process took 3 hours for 50 sequences. Estimate how long the Python parser would take for 50 sequences, 500 sequences, and 5,000 sequences. What does this tell you about the value of automation?