Genome Toolkit. Part 4.4: Loading Sequences From Plain Text

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 algorithms

That 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 algorithms

Notice 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.py

Our current application.py creates the DNA directly:

dna = DNA(
    identifier="example_dna",
    description="Part 4.3 demonstration sequence",
    sequence="aattttaaaac",
)

seq = dna.sequence

and 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 algorithms

This 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
    ↓
DNA

the 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 Protein

The 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          # <-- NEW

Creating 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 = None

There 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 = None

Python 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 = description

And 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 automatically

Removing 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
        ↓
AATTTTAAAAC

If 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.py

Then:

/ "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 algorithms

Now 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 error

This 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
      ↓
algorithms

Now:

sample.txt
      ↓
SequenceRecord
      ↓
DNA
      ↓
dna.sequence
      ↓
algorithms

Our 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 algorithms

We 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         # <-- NEW

Notice 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 run

Those 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
      ↓
algorithm

to:

plain-text file
      ↓
SequenceRecord
      ↓
DNA
      ↓
dna.sequence
      ↓
algorithm

while 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 for SequenceRecord so we get named fields, type information, and a predictable object shape without adding another validation framework.
  • frozen=True in 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.
  • Path and pathlib — Python’s standard way to work with filesystem paths as objects. We use Path to 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 a Path object.
  • Context manager (with) — The with statement 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.txt therefore gives our simple loaded record the identifier sample.
  • model_validate(..., from_attributes=True) — Lets Pydantic create and validate our DNA model by reading matching attributes from SequenceRecord. The conversion still runs all of the Sequence and 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

Video version of this article:

Related Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.