# fastatacular

> Small, dependency-free Python library (>= 3.12) for reading and writing FASTA
> sequence files, with UniProt-style header keys and pipe-delimited identifiers
> parsed into typed fields.

This is the complete usage guide for fastatacular 1.x, written for AI agents and
people who want one self-contained reference. Every example below is a runnable
doctest.

Repository: https://github.com/tacular-omics/fastatacular
PyPI: https://pypi.org/project/fastatacular/
DOI: https://doi.org/10.5281/zenodo.22926358
License: MIT


## 1. Install

    pip install fastatacular
    # or
    uv add fastatacular

No runtime dependencies. Requires Python 3.12 or newer. Pure Python; works on Linux,
macOS and Windows. Files are read and written as UTF-8.


## 2. Public API at a glance

Everything public is importable from the top-level package:

    from fastatacular import (
        read_fasta,        # read a whole FASTA file into a list
        FastaReader,       # stream entries lazily (context manager)
        write_fasta,       # write entries to a path or text handle
        SequenceEntry,     # frozen dataclass, one per FASTA record
        FastaError,        # base of both errors below (ValueError subclass)
        FastaParseError,   # raised on malformed input (FastaError subclass)
        FastaWriteError,   # raised on unwritable entries (FastaError subclass)
        # decoy databases (fastatacular.decoys, see section 8b)
        make_decoys, make_decoy_sequence, write_decoy_fasta, is_decoy,
        MarkovModel, train_markov_model, load_markov_model,
        DecoyError,        # bad decoy option or model (FastaError subclass)
        to_records,        # flat dicts for pandas/polars (section 8c)
        FastaIndex,        # random access by identifier/accession, samtools .fai (section 8d)
        RECORD_KEYS,       # the record keys, in order
    )
    import fastatacular
    fastatacular.__version__   # e.g. "1.1.0"

There is no CLI. The only logging is one warning from `FastaIndex(..., duplicates="first")`. The decoy names live in the public module
`fastatacular.decoys` and are re-exported at the top level. There are no other submodules you need to import directly
(`fastatacular._parser`, `._writer`, `._models` are private; `fastatacular.errors` is
importable but its names are re-exported at the top level).


## 3. Signatures

    read_fasta(source: str | Path | IO[str]) -> list[SequenceEntry]

        Read an entire FASTA file into a list of SequenceEntry objects.
        `source` is a filesystem path (str or pathlib.Path), opened as UTF-8, or an
        already-open text-mode file object (anything iterable over lines, e.g.
        io.StringIO or open(...)). A handle you pass in is not closed.

    class FastaReader(source: str | Path | IO[str])

        Iterate over a FASTA file lazily without loading the entire file.
        Must be used as a context manager:
            with FastaReader(path) as reader:
                for entry in reader: ...
        __enter__ opens the path (UTF-8) if `source` is a str/Path, or uses the
        handle as-is. __exit__ closes the file only if FastaReader opened it.
        Iterating outside the `with` block raises RuntimeError.
        Single-pass: a second iteration continues where the previous one stopped,
        so after a full pass it yields nothing. Open a new reader to re-read.

    write_fasta(entries: Iterable[SequenceEntry],
                dest: str | Path | IO[str],
                *, line_width: int = 60) -> None

        Write entries to FASTA. `dest` is a path (opened "w", UTF-8, overwriting)
        or an open text-mode file object (written to, not closed). `entries` can be
        any iterable, including a generator. `line_width` wraps sequence lines;
        0 or any value <= 0 writes each sequence on a single line.
        All entries are validated before anything is written; on FastaWriteError
        a path `dest` is not created and nothing is written to a handle.

    @dataclass(frozen=True, slots=True)
    class SequenceEntry(
        identifier: str,
        sequence: str,
        prefix: str | None = None,
        accession: str | None = None,
        entry_name: str | None = None,
        description: str | None = None,
        pname: str | None = None,
        gname: str | None = None,
        os_name: str | None = None,
        ncbi_tax_id: int | None = None,
        pe: int | None = None,
        sv: int | None = None,
        extra: dict[str, str] = {},      # default_factory=dict
        raw_header: str = "",
    )

    class FastaError(ValueError)
        Base class of every fastatacular error; `except FastaError` catches both.

    class FastaParseError(FastaError)
        __init__(message: str, *, line: int | None = None, context: str | None = None,
                 hint: str | None = None)
        .line     1-based line number of the problem (or None)
        .context  the offending line text (or None)
        .hint     short fix suggestion (or None); also added as a "hint: ..." note
        str(err)  "Line {line}: {message}" when line is given, else message

    class FastaWriteError(FastaError)
        __init__(message: str, *, index: int | None = None, hint: str | None = None)
        .index    0-based position of the bad entry in `entries` (or None)
        .hint     short fix suggestion (or None); also added as a "hint: ..." note
        str(err)  "Entry {index}: {message}" when index is given, else message


## 4. SequenceEntry fields

| field        | type             | meaning                                                                 |
|--------------|------------------|-------------------------------------------------------------------------|
| identifier   | str              | text after `>` up to first whitespace, e.g. `sp|P12345|EX_HUMAN`        |
| sequence     | str              | sequence lines with all whitespace removed, concatenated                |
| prefix       | str or None      | database tag of a pipe id: `sp`, `tr`, `gi`, ...                        |
| accession    | str or None      | second pipe field: `P12345` in `sp|P12345|EX_HUMAN`                     |
| entry_name   | str or None      | third field, only for exactly-three-field ids `db|ACC|NAME`             |
| description  | str or None      | full text after the identifier, including any KEY=value pairs          |
| pname        | str or None      | protein name: description text before the first KEY=value              |
| gname        | str or None      | `GN=` gene name                                                         |
| os_name      | str or None      | `OS=` organism name                                                     |
| ncbi_tax_id  | int or None      | `OX=` NCBI taxonomy id                                                  |
| pe           | int or None      | `PE=` protein existence level (1-5 in UniProt)                          |
| sv           | int or None      | `SV=` sequence version                                                  |
| extra        | dict[str, str]   | every other KEY=value pair, plus OX/PE/SV values that are not integers |
| raw_header   | str              | the original header line without `>` and line ending                   |

Entries are frozen: assigning a field raises dataclasses.FrozenInstanceError. Use
dataclasses.replace() to derive a modified copy. Entries compare by value (==) but
SequenceEntry is not hashable, because `extra` is a dict: `SequenceEntry.__hash__` is
None, so hash(entry), `{entry}` and using an entry as a dict key raise TypeError.


## 5. Reading

### 5.1 Read a whole file

    >>> import io
    >>> from fastatacular import read_fasta
    >>> text = (
    ...     ">sp|P12345|EX_HUMAN Example protein OS=Homo sapiens OX=9606 GN=EXMP PE=1 SV=2\n"
    ...     "MKTIIALSYI\n"
    ...     "FCLVFA\n"
    ...     ">tr|Q00001|Q00001_MOUSE Another one OS=Mus musculus OX=10090 PE=4 SV=1\n"
    ...     "MAAAK\n"
    ... )
    >>> entries = read_fasta(io.StringIO(text))
    >>> len(entries)
    2
    >>> e = entries[0]
    >>> e.identifier
    'sp|P12345|EX_HUMAN'
    >>> e.sequence
    'MKTIIALSYIFCLVFA'
    >>> (e.prefix, e.accession, e.entry_name)
    ('sp', 'P12345', 'EX_HUMAN')
    >>> e.pname
    'Example protein'
    >>> (e.os_name, e.ncbi_tax_id, e.gname, e.pe, e.sv)
    ('Homo sapiens', 9606, 'EXMP', 1, 2)
    >>> e.description
    'Example protein OS=Homo sapiens OX=9606 GN=EXMP PE=1 SV=2'
    >>> e.raw_header
    'sp|P12345|EX_HUMAN Example protein OS=Homo sapiens OX=9606 GN=EXMP PE=1 SV=2'
    >>> entries[1].gname is None
    True

With a real file, pass the path (str or pathlib.Path):

    entries = read_fasta("proteins.fasta")
    entries = read_fasta(Path("proteins.fasta"))

Compressed files are read transparently (since 1.1): gzip, bzip2 and xz, detected from
the file's magic bytes (a plain file named `.gz` is read as plain text). The path is
opened once, so pipes, FIFOs and `/dev/stdin` work. A Python built without `bz2` or
`lzma` imports fastatacular fine and raises `FastaError` only on such a file. This applies to
paths only; for a handle, open it yourself (`gzip.open(path, "rt")`).

    entries = read_fasta("uniprot_sprot.fasta.gz")

### 5.2 Stream lazily

FastaReader yields one entry at a time, so memory use does not grow with file size.

    >>> from fastatacular import FastaReader
    >>> with FastaReader(io.StringIO(text)) as reader:
    ...     for entry in reader:
    ...         print(entry.accession, len(entry.sequence))
    P12345 16
    Q00001 5

Typical use on disk:

    with FastaReader("uniprot_sprot.fasta") as reader:
        human = [e for e in reader if e.ncbi_tax_id == 9606]

Using the reader without `with` fails:

    >>> r = FastaReader(io.StringIO(text))
    >>> iter(r)
    Traceback (most recent call last):
    ...
    RuntimeError: FastaReader must be used as a context manager (`with FastaReader(...) as r:`)

### 5.3 Identifier shapes

    >>> def one(s):
    ...     return read_fasta(io.StringIO(s))[0]

UniProt `db|ACCESSION|ENTRY_NAME`:

    >>> x = one(">sp|P69905|HBA_HUMAN Hemoglobin subunit alpha\nMVLS\n")
    >>> (x.prefix, x.accession, x.entry_name, x.pname)
    ('sp', 'P69905', 'HBA_HUMAN', 'Hemoglobin subunit alpha')

NCBI-style `db|ID|...` (only prefix and accession are taken):

    >>> x = one(">gi|12345|ref|NP_000001.1| some protein\nAAA\n")
    >>> (x.identifier, x.prefix, x.accession, x.entry_name)
    ('gi|12345|ref|NP_000001.1|', 'gi', '12345', None)

Plain identifier, no description:

    >>> x = one(">contig_7\nACGTACGT\n")
    >>> (x.identifier, x.prefix, x.accession, x.description, x.pname)
    ('contig_7', None, None, None, None)

Plain identifier with free-text description (no KEY=value pairs):

    >>> x = one(">seq1 my favourite protein\nMK\n")
    >>> (x.description, x.pname, x.extra)
    ('my favourite protein', 'my favourite protein', {})

### 5.4 KEY=value parsing

A KEY is `[A-Za-z_][A-Za-z0-9_]*` at the start of the description or after whitespace,
so `Protein(EC=2.7.1)` and `[organism=Homo sapiens]` stay in the name. A value runs until
the next ` KEY=` token or the end of the line, so values may contain spaces (`OS=Homo sapiens`). The five UniProt keys go
into typed fields; anything else lands in `extra`. Integer keys (OX, PE, SV) that do not
parse as int are kept as strings in `extra` rather than raising.

    >>> x = one(">x OX=abc FOO=bar baz\nA\n")
    >>> (x.ncbi_tax_id, x.extra)
    (None, {'OX': 'abc', 'FOO': 'bar baz'})
    >>> x.pname is None
    True

`pname` is None when the description starts with a KEY=value pair.

### 5.5 What the reader skips and keeps

- Blank lines are skipped anywhere.
- Lines starting with `;` (legacy/NCBI comments) are skipped anywhere.
- Lines starting with `#` before the first `>` header (a PEFF file header such as
  `# PEFF 1.0`) are skipped, so a PEFF file reads as plain FASTA: identifier, sequence and
  the raw `\Key=value` description. Use pefftacular to parse the PEFF annotations.
- Windows line endings (`\r\n`) are handled.
- All whitespace (including internal spaces and tabs) is removed from each sequence
  line, then lines are joined. Residue letters are not upper-cased or validated; `*` and `-` are
  kept as-is.

    >>> [y.identifier for y in read_fasta(io.StringIO(";comment\n>a\n\nMK\n\n>b\r\nPE\r\n"))]
    ['a', 'b']
    >>> one(">x\nmk*\n").sequence
    'mk*'


## 6. Writing

### 6.1 Build entries and write

    >>> from fastatacular import SequenceEntry, write_fasta
    >>> new = SequenceEntry(
    ...     identifier="sp|P12345|EX_HUMAN",
    ...     sequence="M" + "A" * 64,
    ...     pname="Example protein",
    ...     os_name="Homo sapiens",
    ...     ncbi_tax_id=9606,
    ...     gname="EXMP",
    ...     pe=1,
    ...     sv=2,
    ...     extra={"TAG": "demo"},
    ... )
    >>> out = io.StringIO()
    >>> write_fasta([new], out)
    >>> print(out.getvalue(), end="")
    >sp|P12345|EX_HUMAN Example protein OS=Homo sapiens OX=9606 GN=EXMP PE=1 SV=2 TAG=demo
    MAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
    AAAAA

When `raw_header` is empty, or no longer matches the entry's fields, the header is rebuilt as:

    >{identifier} {pname or description} OS=.. OX=.. GN=.. PE=.. SV=.. {extra KEY=value ...}

in exactly that order, skipping fields that are None (and the name if both `pname` and
`description` are empty). `pname` is preferred over `description`; from `description`
only the text before the first KEY=value is used as the name.

To a path:

    write_fasta(entries, "out.fasta")            # overwrites
    write_fasta(entries, Path("out.fasta"))

### 6.2 Line width

    >>> out = io.StringIO()
    >>> write_fasta([SequenceEntry("s", "ACDEFGHIK")], out, line_width=4)
    >>> print(out.getvalue(), end="")
    >s
    ACDE
    FGHI
    K
    >>> out = io.StringIO()
    >>> write_fasta([SequenceEntry("s", "ACDEFGHIK")], out, line_width=0)
    >>> print(out.getvalue(), end="")
    >s
    ACDEFGHIK

### 6.3 Round trip

Entries produced by the reader carry `raw_header`, so writing them reproduces the header
line exactly. Sequences are re-wrapped at `line_width`, so the sequence line layout of
the original file is not preserved, but reading the output gives equal entries.

    >>> out = io.StringIO()
    >>> write_fasta(entries, out)
    >>> read_fasta(io.StringIO(out.getvalue())) == entries
    True

### 6.4 Filtering and transforming a file (streaming in, streaming out)

`write_fasta` accepts any iterable, so a generator over a FastaReader processes a file of
any size in constant memory:

    with FastaReader("in.fasta") as reader, open("out.fasta", "w", encoding="utf-8") as fh:
        write_fasta((e for e in reader if e.pe == 1), fh)

### 6.5 Editing parsed entries

The writer uses `raw_header` only while re-parsing it gives the entry's current fields.
Change a structured field and the header is rebuilt, so the edit is written:

    >>> import dataclasses
    >>> edited = dataclasses.replace(entries[0], gname="NEW1")
    >>> out = io.StringIO(); write_fasta([edited], out)
    >>> out.getvalue().splitlines()[0]
    '>sp|P12345|EX_HUMAN Example protein OS=Homo sapiens OX=9606 GN=NEW1 PE=1 SV=2'

For a parsed entry whose `pname` is None, the name text of `description` (before its
first KEY=value) is used, and the keys come from the structured fields, so they are not
repeated.

    >>> e2 = dataclasses.replace(one(">x OS=Homo sapiens GN=A\nA\n"), raw_header="")
    >>> out = io.StringIO(); write_fasta([e2], out)
    >>> out.getvalue().splitlines()[0]
    '>x OS=Homo sapiens GN=A'


## 7. Errors

### 7.1 FastaParseError

Raised by `read_fasta` and during iteration of `FastaReader`. It subclasses FastaError
(and so ValueError).
The message starts with `Line N: `; `.line` is the 1-based line number and `.context`
the offending text.

    >>> from fastatacular import FastaParseError
    >>> try:
    ...     read_fasta(io.StringIO("MKT\n>x\nA\n"))
    ... except FastaParseError as err:
    ...     print(err, "|", err.line, "|", err.context)
    Line 1: Sequence data appears before any '>' header | 1 | MKT

Cases that raise:

| input                                         | message                                    |
|-----------------------------------------------|--------------------------------------------|
| sequence line before the first `>`            | Sequence data appears before any '>' header|
| a `>` line with nothing after it (or spaces)  | Empty FASTA header                         |
| a header with no sequence lines (any entry,   | Entry 'ID' has no sequence data            |
| including the last one in the file)           | (line = the header's line number)          |

    >>> try:
    ...     read_fasta(io.StringIO(">a\nMK\n>b\n>c\nPE\n"))
    ... except FastaParseError as err:
    ...     print(err.line, err)
    3 Line 3: Entry 'b' has no sequence data

With FastaReader the error is raised lazily: entries before the bad one are yielded
first.

    >>> with FastaReader(io.StringIO(">a\nMK\n>b\n>c\nPE\n")) as reader:
    ...     it = iter(reader)
    ...     print(next(it).identifier)
    ...     try:
    ...         next(it)
    ...     except FastaParseError as err:
    ...         print("error at line", err.line)
    a
    error at line 3

A missing file raises the usual FileNotFoundError / OSError, not FastaParseError.
Input that is not UTF-8, and a corrupt or truncated compressed file, raise
FastaParseError ("Cannot read the input after line N: ...") chained to the original
UnicodeDecodeError / OSError / EOFError.

### 7.2 FastaWriteError

    >>> from fastatacular import FastaWriteError
    >>> try:
    ...     write_fasta([SequenceEntry("x", "")], io.StringIO())
    ... except FastaWriteError as err:
    ...     print(err)
    Entry 0: SequenceEntry 'x' has an empty sequence
    >>> try:
    ...     write_fasta([SequenceEntry("", "MK")], io.StringIO())
    ... except FastaWriteError as err:
    ...     print(err, "|", err.index)
    Entry 0: SequenceEntry has an empty identifier | 0

A rebuilt header is read back before writing: a field that would not survive raises
FastaWriteError, e.g. `os_name="Homo sapiens GN=X"` (the `GN=` would split it), an
`extra` key that is not one `[A-Za-z_][A-Za-z0-9_]*` word or is a typed key
(`extra={"OS": ...}`), or a value with leading/trailing whitespace. A `line_width`
that is not an int also raises FastaWriteError (with `index` None).

Every entry is validated before anything is written, so a bad entry anywhere leaves
`dest` untouched: a path is not created or truncated and nothing is written to a
handle.

Catch both with `except ValueError` if you do not care which.


## 8. Recipes

Accession -> sequence map:

    >>> {e.accession: e.sequence for e in entries}
    {'P12345': 'MKTIIALSYIFCLVFA', 'Q00001': 'MAAAK'}

Count entries per organism while streaming:

    from collections import Counter
    with FastaReader("uniprot.fasta") as reader:
        counts = Counter(e.os_name for e in reader)

Keep reviewed (Swiss-Prot) entries only:

    >>> [e.entry_name for e in entries if e.prefix == "sp"]
    ['EX_HUMAN']

Decoy database (reversed sequences, new identifiers, no raw_header):

    >>> decoys = [
    ...     SequenceEntry(identifier=f"rev_{e.identifier}", sequence=e.sequence[::-1])
    ...     for e in entries
    ... ]
    >>> out = io.StringIO(); write_fasta(entries + decoys, out, line_width=0)
    >>> print(out.getvalue().splitlines()[4])
    >rev_sp|P12345|EX_HUMAN

A decoy or contaminant tag becomes part of the prefix, so the accession is kept
(`Reverse_sp|...`, `DECOY-0-sp|...` and `contam_sp|...` work the same way):

    >>> x = one(">rev_sp|P12345|EX_HUMAN\nA\n")
    >>> (x.prefix, x.accession, x.entry_name)
    ('rev_sp', 'P12345', 'EX_HUMAN')

Gzipped input: open it yourself in text mode and pass the handle.

    import gzip
    with gzip.open("uniprot.fasta.gz", "rt", encoding="utf-8") as fh:
        with FastaReader(fh) as reader:
            for e in reader: ...


## 8b. Decoy databases

Full guide: docs/decoys.md. Signatures (options shared by all four functions):

    make_decoys(entries: Iterable[SequenceEntry], *, method, prefix="DECOY_",
                seed: int | str | bytes | None = None, keep_residues: str | None = None,
                keep_nterm=0, keep_cterm=0, k=2, model="human") -> Iterator[SequenceEntry]
    make_decoy_sequence(sequence: str, *, method, seed=None, keep_residues=None,
                        keep_nterm=0, keep_cterm=0, k=2, model="human") -> str
    write_decoy_fasta(src, dst, *, method, concatenate=True, prefix="DECOY_", seed=None,
                      keep_residues=None, keep_nterm=0, keep_cterm=0, k=2, model="human",
                      line_width=60) -> int   # number of decoys written
    is_decoy(entry: SequenceEntry | str, *, prefix="DECOY_") -> bool
    train_markov_model(fasta_paths, *, order=2, metadata=None) -> MarkovModel
    load_markov_model(name_or_path) -> MarkovModel   # "human", "mouse", "yeast", "ecoli" or a path
    MarkovModel.save(path)                           # .json or .json.gz

- method: "reverse", "pseudo_reverse", "shuffle", "debruijn" (Moosa et al. 2020,
  repeat-preserving, k = k-mer length) or "markov" (model = name, path or MarkovModel).
- keep_residues: residues fixed in place and never drawn as replacements. Default "KR"
  for pseudo_reverse, "" otherwise. keep_nterm / keep_cterm: terminal residues kept.
- Same seed, method and options give the same output; seed=None is random.
- The decoy header is prefix + the target header, so accession and the other fields are
  the target's; only identifier (and prefix) change.
- debruijn reads every entry before yielding (labels depend on the whole database); the
  other methods stream, and each decoy depends only on its own target.
- Bad options raise DecoyError when make_decoys is called. write_decoy_fasta raises
  DecoyError if src already has prefixed entries.

    >>> from fastatacular import make_decoy_sequence
    >>> make_decoy_sequence("ABCKDEFRGH", method="pseudo_reverse")
    'CBAKFEDRHG'
    >>> make_decoy_sequence("MPEPTIDEK", method="reverse", keep_nterm=1, keep_cterm=1)
    'MEDITPEPK'


## 8c. Tables (pandas, polars)

    to_records(source: str | Path | IO[str] | Iterable[SequenceEntry]) -> list[dict]
    FastaReader.to_records() -> list[dict]      # remaining entries
    SequenceEntry.to_record() -> dict

One plain dict per entry with the keys of RECORD_KEYS, in this order: identifier,
prefix, accession, entry_name, pname, gname, os_name, ncbi_tax_id (int), pe (int),
sv (int), description, extra ("KEY=value KEY2=value2" or None), raw_header,
length (int), sequence. Missing values are None.
Record keys differ from pefftacular's because each follows its own model. To stack the
two in one frame, rename: accession<->db_unique_id, entry_name<->id, os_name<->tax_name
(prefix, pname, gname, ncbi_tax_id, pe, sv, length, sequence are shared; `extra` is
"KEY=value" text here and "\Key=value" text there).
fastatacular does not depend on pandas or polars; pass the list to them yourself:

    import pandas as pd
    df = pd.DataFrame(to_records("human.fasta"))

    >>> import io
    >>> from fastatacular import to_records
    >>> rec = to_records(io.StringIO(">sp|P1|A_HUMAN Name OX=9606 XX=1\nMK\n"))[0]
    >>> rec["accession"], rec["ncbi_tax_id"], rec["extra"], rec["length"]
    ('P1', 9606, 'XX=1', 2)


## 8d. Random access (FastaIndex)

    FastaIndex(path, *, key="identifier", duplicates="error")  # one pass, uncompressed file
    FastaIndex.from_fai(path, fai_path=None, *, key="identifier", duplicates="error")
    index.write_fai(fai_path=None) -> Path           # samtools-compatible .fai
    index[k] -> SequenceEntry                        # reads only that entry
    k in index; len(index); list(index)              # keys in file order, no file access
    index.locate(k) -> (offset, length)              # byte range from '>' to the next '>'
    index.identifier(k) -> str                       # first header word (.fai name)

- FastaIndex is a read-only Mapping[str, SequenceEntry]. key="identifier" (default):
  the first header word, the .fai name; works on target-decoy files (sp|P1|X and
  DECOY_sp|P1|X share an accession). key="accession": the accession, or the whole
  identifier when it has no '|'. A duplicate key -> FastaError naming the first repeat
  (in accession mode, with a hint to use key="identifier"). duplicates="first" keeps
  the first entry per key and skips later ones (like samtools faidx), logging one
  WARNING on logger "fastatacular._index" with the count; skipped entries are not in
  the index or its .fai. Missing key -> FastaKeyError (subclass of FastaError and
  KeyError).
- Compressed input (gzip, bgzip, bzip2, xz) -> FastaError; decompress first. A path
  that is not a regular file (FIFO, pipe, directory) -> FastaError; a missing file ->
  FileNotFoundError.
- write_fai raises FastaError for entries with uneven line lengths or blank/comment
  lines inside the sequence (samtools rejects those too), and for entries with no
  sequence (samtools skips them). Trailing spaces count in the line width, not the
  residues, as in samtools. The output matches samtools faidx byte for byte.
  samtools refuses a FASTA that starts with a BOM; its .fai works only with FastaIndex.
- from_fai checks each header line against its .fai name, not the residue counts:
  rebuild the .fai when the FASTA changes. The file is re-opened on every lookup.

    >>> import pathlib, tempfile
    >>> from fastatacular import FastaIndex
    >>> p = pathlib.Path(tempfile.mkdtemp()) / "x.fasta"
    >>> _ = p.write_text(">sp|P1|A_HUMAN a\nMKV\n>sp|P2|B_HUMAN b\nAAAA\n")
    >>> index = FastaIndex(p)
    >>> list(index), index["sp|P2|B_HUMAN"].sequence, "P2" in index
    (['sp|P1|A_HUMAN', 'sp|P2|B_HUMAN'], 'AAAA', False)
    >>> by_acc = FastaIndex(p, key="accession")
    >>> list(by_acc), by_acc["P2"].identifier
    (['P1', 'P2'], 'sp|P2|B_HUMAN')
    >>> index.write_fai().read_text().splitlines()
    ['sp|P1|A_HUMAN\t3\t17\t3\t4', 'sp|P2|B_HUMAN\t4\t38\t4\t5']


## 9. Gotchas

1. A `str` argument is always treated as a file path. `read_fasta(">x\nA\n")` raises
   FileNotFoundError. Wrap FASTA text in io.StringIO.
2. `raw_header` is written verbatim only while it still parses to the entry's fields.
   Edit a field (`dataclasses.replace`) and the header is rebuilt from the fields;
   a hand-built `raw_header` that disagrees with them is ignored.
3. When `raw_header` is empty and `pname` is None, the writer uses the name text of
   `description` (before its first KEY=value); keys come from the structured fields
   and `extra`, not copied from `description`.
4. The identifier is the text before the first whitespace (space or tab):
   `>x\tdesc` gives identifier `'x'`.
5. All whitespace is removed from sequence lines, including internal spaces. No case
   folding and no alphabet check: DNA, RNA, protein, lower case, `*`, `-` all pass.
6. `description` includes the KEY=value text; use `pname` for just the name.
7. `entry_name` is only set for exactly three pipe fields with no spaces in the third;
   `accession` is the second field.
8. Non-integer OX/PE/SV values go into `extra` as strings instead of raising.
9. Duplicate keys: the last occurrence wins (typed field or `extra` entry overwritten).
10. SequenceEntry is frozen and not hashable (`__hash__` is None); use `dataclasses.replace` to modify and
    key dicts by `identifier`/`accession`, not by the entry.
11. Entries with an empty sequence cannot be read (FastaParseError) or written
    (FastaWriteError).
12. Encoding is fixed to UTF-8 for paths (plain or .gz/.bz2/.xz). For other encodings
    open the file yourself and pass the handle.


## 10. Related packages

- pefftacular (https://github.com/tacular-omics/pefftacular): PEFF (PSI Extended FASTA)
  reader/writer with the same API shape (`read_peff`, `PeffReader`, `write_peff`).
- tacular-omics (https://github.com/tacular-omics): the family of proteomics packages
  this belongs to (tacular, peptacular, paftacular, spxtacular, ...).


## 11. Citation

Cite the archived release: DOI 10.5281/zenodo.22926358
(https://doi.org/10.5281/zenodo.22926358). Machine-readable metadata is in CITATION.cff
in the repository.
