Welcome back to the Genome Toolkit series!
In Part 4.3, we added our first validated biological models. Genome Toolkit can now create a DNA object, normalize lowercase DNA to uppercase, reject unsupported symbols, and keep useful information such as the sequence identifier and description together with the biological data.
Most importantly, we deliberately left our existing k-mer algorithms unchanged.
They still accept a normal Python string:
sequence: str
and our application passes them the validated sequence through:
seq = dna.sequence
So the end of our current workflow still looks like this:
DNA(...)
↓
validated DNA
↓
dna.sequence
↓
our existing k-mer algorithmsThat is exactly where we want to continue from.
Our next limitation is no longer DNA validation. The sequence itself is still written directly inside application.py:
sequence="aattttaaaac",
Real biological data usually comes from somewhere outside our Python source code, so in this part we are going to add the smallest possible loading layer and read one sequence from a plain-text file.
We will add a small neutral SequenceRecord, create a plain-text loader, convert the loaded record into our existing DNA model, and then continue using the same two k-mer algorithms exactly as before.
The new workflow will become:
sample.txt
↓
text.get_sequence()
↓
SequenceRecord
↓
DNA
↓
dna.sequence
↓
our existing k-mer algorithmsNotice what is not changing:
algorithms/kmer.py
We are not changing the algorithm signatures, the loops, or the return values in Part 4.4. This article is about getting biological data into Genome Toolkit from a file.
Starting With Our Working Project
We continue from the exact final state of Part 4.3.
Our project currently looks like this:
genome_toolkit/
├── .git/
├── .gitignore
├── .venv/
├── README.md
├── application.py
├── pyproject.toml
├── uv.lock
└── src/
└── genome_toolkit/
├── __init__.py
├── py.typed
├── algorithms/
│ ├── __init__.py
│ └── kmer.py
└── sequence/
├── __init__.py
├── base.py
└── dna.pyOur current application.py creates the DNA directly:
dna = DNA(
identifier="example_dna",
description="Part 4.3 demonstration sequence",
sequence="aattttaaaac",
)
seq = dna.sequenceand the existing algorithms still receive:
count_kmer(seq, kmer) find_most_frequent_kmers(seq, k_len)
Running the complete Part 4.3 application gives us the same scientific checkpoint we have been preserving throughout the refactor:
Sequence: AATTTTAAAAC k-mer: AA Repeats found: 4 Most frequent k-mer: ['TTT', 'AAA']
In Part 4.4, we want to keep that result while changing only where the original sequence comes from.
What Are We Going to Add?
We need two small pieces inside a new loading package:
SequenceRecord text.get_sequence()
SequenceRecord will hold the values that a loader has parsed from an external source.
text.get_sequence() will read one sequence from a plain-text file and return that record.
Then our existing DNA model will decide whether the loaded symbols are actually valid DNA:
plain-text file
↓
loader reads the file
↓
SequenceRecord
↓
DNA validates the biology
↓
dna.sequence
↓
existing algorithmsThis separation is important because reading a file and deciding what the biological symbols mean are two different jobs.
Why Not Return DNA Directly?
Imagine that our text file contains:
ACGTACGT
That could be DNA.
But a plain-text file could just as easily contain:
ACGUACGU
for RNA, or:
MKTLLVAG
for a protein sequence.
The file format itself does not tell us which biological type the symbols are supposed to represent.
If our text loader returned DNA directly:
text loader
↓
DNAthe loader would be making two decisions at once:
How do I read this file? + What biological type is this sequence?
Instead, we will keep those steps separate:
text file
↓
SequenceRecord
↓
DNA / future RNA / future ProteinThe loader tells us what it parsed. The biological model tells us whether those parsed symbols satisfy the rules for DNA, RNA, protein, or another future sequence type.
This also means we can reuse the same loading code later without teaching the text loader about every biological model Genome Toolkit may eventually support.
Creating the load Package
Inside:
src/genome_toolkit/
create a new folder:
load/
We will build it one file at a time.
The first file we need is:
records.py
So our project temporarily becomes:
src/
└── genome_toolkit/
├── algorithms/
├── sequence/
└── load/ # <-- NEW
└── records.py # <-- NEWCreating a Neutral SequenceRecord
Open:
src/genome_toolkit/load/records.py
and add:
"""Lightweight records returned by sequence loaders."""
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class SequenceRecord:
"""Parsed sequence data before biological validation."""
identifier: str
sequence: str
description: str | None = NoneThere are a few new Python ideas here, so let us look at them before moving on.
What Is a Dataclass and Why Are We Using It?
We import:
from dataclasses import dataclass
dataclass comes from Python’s standard library. It is designed for small classes whose main job is to hold data.
When we write:
@dataclass(frozen=True, slots=True)
class SequenceRecord:
identifier: str
sequence: str
description: str | None = NonePython turns those typed fields into a useful data object for us.
Without @dataclass, we would need to write the basic setup ourselves:
class SequenceRecord:
def __init__(
self,
identifier: str,
sequence: str,
description: str | None = None,
):
self.identifier = identifier
self.sequence = sequence
self.description = descriptionAnd that is only the beginning. If we also wanted the same useful object representation, easy comparisons, immutability, and fixed attributes, we would need to write even more code ourselves.
With @dataclass, we get useful behavior automatically:
typed named fields automatic __init__() useful __repr__() optional immutability with frozen=True compact memory and fixed attributes with slots=True standard library only
For SequenceRecord, this fits very well because the object has no special behavior to invent. Its job is simply:
Carry parsed sequence data from a loader to a biological model.
It is still just a simple Python object carrying data. @dataclass removes the repetitive plumbing so we can focus on what the record actually contains.
The line:
@dataclass(frozen=True, slots=True)
is also a Python decorator. We already saw decorators such as @field_validator and @classmethod in Part 4.3. Here, the decorator tells Python to process SequenceRecord as a dataclass and apply the two options we supplied.
Check out this very helpful video to learn more about why you should be using Python dataclasses in your propjects:
Why Not Use Another Pydantic Model?
We already use Pydantic for Sequence and DNA, so why not use it here too?
Because SequenceRecord has a much smaller job.
It represents parsed external data before biological validation:
file ↓ SequenceRecord ↓ DNA validation
We do not need SequenceRecord to decide whether the sequence is DNA, normalize nucleotides, or provide Pydantic validation rules. Our existing biological models already do that.
Compared with a plain dictionary such as:
{
"id": "sample",
"seq": "ACGT",
}our dataclass gives us named fields:
record.identifier record.sequence record.description
along with type information, editor autocomplete, and one predictable shape.
What Does frozen=True Do Here?
We used the same idea in our Pydantic model configuration in Part 4.3.
For a dataclass:
frozen=True
means that after we create the record, its fields cannot simply be reassigned.
For example, we do not want loading code to create:
record.sequence = "AAAA"
later by accident.
The record represents what we parsed from the source file, so keeping those parsed values fixed makes the handoff into biological validation easier to reason about.
What Does slots=True Do?
Normally, Python objects can often have new attributes added to them dynamically.
For example, without slots, code could accidentally try to attach something unrelated such as:
record.random_value = 123
slots=True restricts the object to the fields we actually defined:
identifier sequence description
It also makes small data objects somewhat more compact in memory. For us, the main practical benefit is that SequenceRecord stays a simple, predictable data container instead of quietly growing arbitrary attributes.
The Three Record Fields
Our record contains:
identifier: str sequence: str description: str | None = None
The identifier gives the parsed sequence a name. The sequence contains the symbols read from the source, and description is optional because a simple text file may not provide one.
At this point we have a useful shape for loaded sequence data, but nothing is reading a file yet. Now we can build the loader that creates this record.
Adding the Plain-Text Loader
Create our second file:
src/genome_toolkit/load/text.py
Our loading package now contains:
load/ ├── records.py └── text.py # <-- NEW
Add:
"""Loader for plain-text sequence files."""
from pathlib import Path
from .records import SequenceRecord
def get_sequence(filepath: str | Path) -> SequenceRecord:
"""Load one sequence and remove all whitespace.
Args:
filepath: Path to a plain-text file containing one sequence.
Returns:
A neutral sequence record containing the parsed sequence.
Raises:
ValueError: If the file contains no sequence data.
"""
path = Path(filepath)
with path.open("r", encoding="utf-8") as file:
sequence = "".join("".join(line.split()) for line in file)
if not sequence:
raise ValueError(f"'{path}' does not contain sequence data.")
return SequenceRecord(
identifier=path.stem,
sequence=sequence,
)This is the first actual file loader in Genome Toolkit, so let us go through it from top to bottom.
Working With File Paths Using Path
We begin with:
from pathlib import Path
pathlib is part of Python’s standard library, and Path gives us an object for working with filesystem paths.
Instead of treating a path only as a string such as:
"samples/sample.txt"
we can work with:
Path("samples/sample.txt")and use useful path operations such as opening the file or getting its filename without the extension.
Our function accepts:
filepath: str | Path
The | means either type is accepted.
So both of these are valid:
text.get_sequence("samples/sample.txt")and:
text.get_sequence(Path("samples/sample.txt"))The first line inside the function:
path = Path(filepath)
converts either form into one consistent Path object. From that point onward, the rest of the loader only needs to work with path.
Opening the File
Next we have:
with path.open("r", encoding="utf-8") as file:The "r" means we are opening the file for reading.
encoding="utf-8" tells Python how the text inside the file should be decoded. UTF-8 is the standard text encoding we will use for our sequence files.
The with statement creates a context manager. In simple terms, Python opens the file for this block and automatically closes it again when the block finishes, even if something goes wrong while we are reading it.
So instead of manually doing:
open file read file remember to close file
we get:
with file open
read what we need
leave block
file is closed automaticallyRemoving Whitespace From the Sequence
Inside the file block we use:
sequence = "".join("".join(line.split()) for line in file)There are several small operations packed into this one line.
For each line:
line.split()
splits the line around whitespace.
For example:
"AATT TTAAn"
becomes pieces similar to:
"AATT" "TTAA"
Then:
"".join(line.split())
joins those pieces back together without spaces:
AATTTTAA
The expression:
for line in file
processes the file one line at a time, and the outer:
"".join(...)
joins all of those cleaned lines into one final sequence.
So a file containing:
AATTTT AAAAC
becomes:
AATTTTAAAAC
For this simple plain-text format, whitespace is formatting. It is not part of the biological sequence we want to pass into our model.
Rejecting an Empty Sequence
After reading the file we check:
if not sequence:
raise ValueError(f"'{path}' does not contain sequence data.")If the file contains no sequence after whitespace has been removed, there is nothing useful for the loader to return.
Rather than creating an empty record and allowing the problem to travel further into our program, we stop here with a clear error.
We will build a more deliberate error contract later in the series. For now, the ordinary ValueError from our technical plan is enough to communicate that this source did not contain the value our loader expected.
Creating the SequenceRecord
Finally:
return SequenceRecord(
identifier=path.stem,
sequence=sequence,
)creates the neutral record.
The new expression:
path.stem
means the filename without its extension.
For:
samples/sample.txt
the stem is:
sample
so the returned record contains approximately:
identifier → sample sequence → AATTTTAAAAC description → None
Notice that the loader has still made no DNA decision.
If the text file contained invalid DNA symbols, text.get_sequence() could still return a SequenceRecord. The biological check happens in the next step when we try to turn that record into DNA.
Exposing Our Loading API
Now that the actual text loader exists, create:
src/genome_toolkit/load/__init__.py
Our loading package is now:
load/ ├── __init__.py # <-- NEW ├── records.py └── text.py
Add:
"""Sequence loaders and neutral records.""" from . import text from .records import SequenceRecord __all__ = ["SequenceRecord", "text"]
This gives us a clean public loading API.
Instead of importing the function from its internal module path:
from genome_toolkit.load.text import get_sequence
we can write:
from genome_toolkit.load import text
and then call:
text.get_sequence(...)
Keeping the source name visible will also scale naturally when we add another loader:
text.get_sequence(...) fasta.get_sequence(...)
That is clearer than creating increasingly long names such as:
get_text_sequence() get_fasta_sequence()
in one flat namespace.
Adding Our First Sample File
At the repository root, create a new folder:
samples/
Then create:
samples/sample.txt
with:
AATTTT AAAAC
We are deliberately using the same original Genome Toolkit sequence we have used throughout the series:
AATTTTAAAAC
The only difference is that it is now stored outside our Python code and wrapped across two lines.
This gives us a very useful checkpoint:
hardcoded sequence
↓
AATTTTAAAAC
plain-text file
↓
AATTTTAAAACIf the loading layer is working correctly, the scientific calculation at the end of the workflow should still see the same sequence.
Loading Our First SequenceRecord
Now we can connect the new loader to our existing application.
Add:
from pathlib import Path
and:
from genome_toolkit.load import text
Then define the location of our sample files:
SAMPLES_DIR = Path(__file__).resolve().parent / "samples"
There are three useful path operations here:
__file__
current Python file
.resolve()
absolute filesystem path
.parent
directory containing application.pyThen:
/ "samples"
uses Path‘s / operator to build the path to our samples/ directory.
So regardless of where we run the command from, application.py can find the sample directory relative to its own location.
Now load the file:
record = text.get_sequence(
SAMPLES_DIR / "sample.txt"
)At this point:
record.identifier
is:
sample
and:
record.sequence
is:
AATTTTAAAAC
But this is still only parsed data. We have not yet asked our biological model whether the sequence is valid DNA.
Converting the Record Into Validated DNA
This is where Part 4.3 connects directly to our new loading layer.
Our SequenceRecord contains:
identifier description sequence
and our DNA model expects those same field names.
Before we use any Pydantic shortcut, let us connect them manually so we can see exactly what is happening.
First, Map the Fields Manually
After loading our record:
record = text.get_sequence(
SAMPLES_DIR / "sample.txt"
)we can create the DNA object exactly the same way we created DNA in Part 4.3:
dna = DNA(
identifier=record.identifier,
description=record.description,
sequence=record.sequence,
)There is nothing wrong with this approach.
We are simply taking each value from:
SequenceRecord
and assigning it to the matching field in:
DNA
The mapping is easy to see:
record.identifier → DNA.identifier record.description → DNA.description record.sequence → DNA.sequence
Our existing bridge to the algorithms can then remain:
seq = dna.sequence
and the same algorithm calls still work:
count_kmer(seq, kmer) find_most_frequent_kmers(seq, k_len)
If we run:
uv run python application.py
the important scientific checkpoint is still:
Sequence: AATTTTAAAAC k-mer: AA Repeats found: 4 Most frequent k-mer: ['TTT', 'AAA']
So we have already completed the important connection:
sample.txt
↓
SequenceRecord
↓
manual field mapping
↓
DNA
↓
dna.sequence
↓
existing algorithmsNow we can simplify one repetitive part.
Let Pydantic Read the Matching Fields for Us
Notice that both objects intentionally use the same field names:
SequenceRecord DNA -------------- --- identifier → identifier description → description sequence → sequence
Writing:
dna = DNA(
identifier=record.identifier,
description=record.description,
sequence=record.sequence,
)is clear, but we are manually copying three matching fields.
Because DNA is a Pydantic model, it already has:
model_validate()
We can therefore replace the manual mapping with:
dna = DNA.model_validate(
record,
from_attributes=True,
)This does not change our biological validation. It only changes how the values from record are supplied to the DNA model.
What Does model_validate() Do?
model_validate() is a Pydantic method for creating and validating a model from another value.
In our case:
source object → SequenceRecord target model → DNA
The important option is:
from_attributes=True
That tells Pydantic to read matching values from the source object’s attributes.
So instead of us manually writing:
identifier=record.identifier description=record.description sequence=record.sequence
Pydantic reads those matching attributes for us.
The flow becomes:
SequenceRecord
↓
DNA.model_validate(
record,
from_attributes=True,
)
↓
read matching attributes
↓
run inherited Sequence rules
↓
run DNA validator
↓
valid → DNA object created
invalid → Pydantic validation errorThis is important: model_validate() does not bypass anything we built in Part 4.3.
If the loaded record contains:
AATTTTZAAAC
our DNA validator still sees the Z, rejects it, and no valid DNA object is created.
So our manual version:
dna = DNA(
identifier=record.identifier,
description=record.description,
sequence=record.sequence,
)and our shorter Pydantic version:
dna = DNA.model_validate(
record,
from_attributes=True,
)both end at the same place:
validated DNA object
The second version simply avoids repeating field-by-field assignments when the two objects were deliberately designed with matching names.
When Should We Use This Conversion?
Automatic attribute conversion is convenient when the source object and target model intentionally share the same core schema.
For our current models, that is exactly what we have.
A few practical rules are useful to remember:
record has extra fields → unused source attributes can be ignored DNA has optional/defaulted fields → their defaults can still be used DNA requires a field the record does not provide → validation fails matching record field contains invalid DNA → DNA validation fails field names do not match → map the fields explicitly
So:
model_validate(..., from_attributes=True)
is not magic conversion between any two Python objects. It is a convenient way to replace the manual mapping we just wrote when the source and target intentionally use matching attributes.
Updating Our Existing application.py
Now we can update the complete application.
In Part 4.3, we added several extra print() calls to demonstrate what our new DNA model could do: length, indexing, slicing, iteration, and JSON serialization. Those examples were useful while we were learning the model, but we do not need to keep all of them in every later version of application.py.
From this point onward, we will return to a cleaner application that focuses on the current data flow and our original scientific checkpoint.
Before Part 4.4:
hardcoded string
↓
DNA
↓
dna.sequence
↓
algorithmsNow:
sample.txt
↓
SequenceRecord
↓
DNA
↓
dna.sequence
↓
algorithmsOur algorithm boundary remains unchanged.
Update application.py to:
from pathlib import Path
from genome_toolkit.algorithms import (
count_kmer,
find_most_frequent_kmers,
)
from genome_toolkit.load import text
from genome_toolkit.sequence import DNA
SAMPLES_DIR = Path(__file__).resolve().parent / "samples"
record = text.get_sequence(
SAMPLES_DIR / "sample.txt"
)
dna = DNA.model_validate(
record,
from_attributes=True,
)
seq = dna.sequence
kmer = "AA"
k_len = 3
print(f"Sequence: {seq}")
print(f"k-mer: {kmer}")
print(f"Repeats found: {count_kmer(seq, kmer)}")
print(f"Most frequent k-mer: {find_most_frequent_kmers(seq, k_len)}")Compare the last two calls with Part 4.3:
count_kmer(seq, kmer) find_most_frequent_kmers(seq, k_len)
They are exactly the same.
And:
seq = dna.sequence
is still exactly the same bridge we established in Part 4.3.
That means we have added external file loading without touching:
src/genome_toolkit/algorithms/kmer.py
at all.
Running the Complete Part 4.4 Application
Run:
uv run python application.py
We should now see:
Sequence: AATTTTAAAAC k-mer: AA Repeats found: 4 Most frequent k-mer: ['TTT', 'AAA']
Our loader still derives the identifier sample from sample.txt, and the validated DNA object still contains that information internally. We simply no longer print every model feature in the main application because those behaviors were already demonstrated in Part 4.3.
The important scientific data is still:
AATTTTAAAAC
and our original calculations still return:
Repeats found: 4 Most frequent k-mer: ['TTT', 'AAA']
So our complete path is now:
sample.txt
↓
text.get_sequence()
↓
SequenceRecord
↓
DNA.model_validate(...)
↓
validated DNA
↓
dna.sequence
↓
existing k-mer algorithmsWe added an external source without changing the biological model and without changing either algorithm.
Our New Project Structure
At the end of Part 4.4, our project looks like this:
genome_toolkit/
├── .git/
├── .gitignore
├── .venv/
├── README.md
├── application.py # <-- UPDATED
├── pyproject.toml
├── uv.lock
├── samples/ # <-- NEW
│ └── sample.txt # <-- NEW
└── src/
└── genome_toolkit/
├── __init__.py
├── py.typed
├── algorithms/
│ ├── __init__.py
│ └── kmer.py
├── sequence/
│ ├── __init__.py
│ ├── base.py
│ └── dna.py
└── load/ # <-- NEW
├── __init__.py # <-- NEW
├── records.py # <-- NEW
└── text.py # <-- NEWNotice what stayed untouched:
algorithms/ sequence/ pyproject.toml uv.lock
We did not add another dependency, and we did not redesign the model or algorithm layers.
The new pieces are only the files needed to bring a simple external sequence into the workflow.
Why This Small Loading Layer Matters
At first, reading one text file may not look like a large feature.
But it gives Genome Toolkit an important new separation:
where data comes from
↓
what data was parsed
↓
what biological type it represents
↓
what scientific calculation we runThose steps can now evolve independently.
A future FASTA loader can produce the same SequenceRecord. A database or API loader could eventually do the same. Our DNA model can validate those records without learning how each source works, and our k-mer algorithms can continue focusing only on their calculations.
For Part 4.4, however, we keep that architecture deliberately small. We support one plain-text file containing one sequence, and nothing more.
Summary
In Part 4.4, we moved the original Genome Toolkit sequence out of application.py and into our first external sample file.
We created a new load/ package, introduced the neutral SequenceRecord dataclass, and built text.get_sequence() to read a plain-text sequence file, remove whitespace, reject an empty source, and return the parsed sequence together with an identifier.
We then used Pydantic’s DNA.model_validate(..., from_attributes=True) to turn that neutral record into the same validated DNA model we built in Part 4.3.
Most importantly, we did not modify our two k-mer algorithms. They still accept strings, and application.py still passes them:
dna.sequence
through our existing:
seq = dna.sequence
bridge.
Our workflow has therefore grown from:
hardcoded string
↓
DNA
↓
algorithmto:
plain-text file
↓
SequenceRecord
↓
DNA
↓
dna.sequence
↓
algorithmwhile the original k-mer calculations remain unchanged.
New Concepts We Learned
SequenceRecord— A small neutral object that carries sequence data parsed from an external source before we decide which biological model should validate it.
dataclass— A Python standard-library tool for creating classes whose main purpose is to hold data. We use it forSequenceRecordso we get named fields, type information, and a predictable object shape without adding another validation framework.
frozen=Truein a dataclass — Prevents the parsed record fields from being reassigned after the object is created. This helps the record continue to represent the data we actually loaded from the source.
slots=True— Restricts the dataclass to the fields we explicitly defined and prevents arbitrary new attributes from being attached later. It also makes small data objects more compact.
Pathandpathlib— Python’s standard way to work with filesystem paths as objects. We usePathto open files, combine path components, resolve locations, and derive the sample identifier from the filename.
str | Path— A type annotation meaning that our loader accepts either a normal path string or aPathobject.
- Context manager (
with) — Thewithstatement manages the lifetime of the open file for us. Python opens the file for the block and closes it automatically when the block ends.
- UTF-8 — The text encoding we explicitly use when reading our sequence files so Python knows how to decode the file contents.
Path.stem— Gives us the filename without its extension.sample.txttherefore gives our simple loaded record the identifiersample.
model_validate(..., from_attributes=True)— Lets Pydantic create and validate ourDNAmodel by reading matching attributes fromSequenceRecord. The conversion still runs all of theSequenceand DNA validation rules from Part 4.3.
The most important new idea is that parsing external data and validating biology are separate steps. The loader tells us what it read; DNA decides whether those symbols are valid DNA.
What is Next?
Our plain-text loader now gives Genome Toolkit a real external data source, but the format is deliberately minimal.
A plain-text file does not contain the record structure commonly used by real biological sequence collections. FASTA files can contain:
identifiers descriptions wrapped sequence lines multiple biological records
So in Part 4.5, we will extend only the loading layer and add our first minimal streaming FASTA loader.
The workflow will become:
plain text ─┐
├→ SequenceRecord → DNA → dna.sequence → algorithms
FASTA ──────┘And just like Part 4.4, Part 4.5 will leave our existing DNA model and k-mer algorithms unchanged.
The full source code for Genome Toolkit is available here:
https://github.com/rebelC0der/Genome_Toolkit
I hope adding our first plain-text sequence loader and connecting external sequence data to our validated DNA model was useful for your bioinformatics and programming journey! If you found this article valuable and want to help us continue building rebelScience, please consider supporting our project. You can explore various ways to contribute here:
https://rebelscience.club/cryptocurrency-donations/
Until next time, rebelCoder, signing out.
References
- Python
dataclasses: https://docs.python.org/3/library/dataclasses.html - Python
pathlib: https://docs.python.org/3/library/pathlib.html - Pydantic model validation: https://docs.pydantic.dev/latest/concepts/models/
Video version of this article: