fastatacular 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,58 @@
1
+ name: Python Package
2
+
3
+ on:
4
+ push:
5
+ paths:
6
+ - 'src/**'
7
+ - 'tests/**'
8
+ workflow_dispatch:
9
+
10
+ jobs:
11
+ test:
12
+ runs-on: ubuntu-latest
13
+ strategy:
14
+ fail-fast: false
15
+ matrix:
16
+ python-version: ["3.12", "3.13"]
17
+
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+
21
+ - name: Install uv
22
+ uses: astral-sh/setup-uv@v5
23
+ with:
24
+ enable-cache: false
25
+
26
+ - name: Set up Python ${{ matrix.python-version }}
27
+ run: uv python install ${{ matrix.python-version }}
28
+
29
+ - name: Install just
30
+ uses: extractions/setup-just@v2
31
+
32
+ - name: Install dependencies
33
+ run: just install
34
+
35
+ - name: Lint
36
+ run: just lint
37
+
38
+ - name: Type check
39
+ run: uv run ty check src
40
+
41
+ - name: Test with coverage
42
+ run: just test-cov
43
+
44
+ - name: Upload coverage to Codecov
45
+ uses: codecov/codecov-action@v5
46
+ with:
47
+ token: ${{ secrets.CODECOV_TOKEN }}
48
+ slug: tacular-omics/fastatacular
49
+ fail_ci_if_error: false
50
+
51
+ - name: Upload test results to Codecov
52
+ if: ${{ !cancelled() }}
53
+ uses: codecov/test-results-action@v1
54
+ with:
55
+ token: ${{ secrets.CODECOV_TOKEN }}
56
+ slug: tacular-omics/fastatacular
57
+ files: junit.xml
58
+ fail_ci_if_error: false
@@ -0,0 +1,32 @@
1
+ name: Upload Python Package
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ deploy:
12
+ runs-on: ubuntu-latest
13
+ permissions:
14
+ contents: read
15
+ id-token: write
16
+
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+
20
+ - name: Set up Python
21
+ uses: actions/setup-python@v5
22
+ with:
23
+ python-version: '3.x'
24
+
25
+ - name: Install uv
26
+ uses: astral-sh/setup-uv@v5
27
+
28
+ - name: Build package
29
+ run: uv build
30
+
31
+ - name: Publish to PyPI
32
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,84 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ *.manifest
31
+ *.spec
32
+
33
+ # Installer logs
34
+ pip-log.txt
35
+ pip-delete-this-directory.txt
36
+
37
+ # Unit test / coverage reports
38
+ htmlcov/
39
+ .tox/
40
+ .nox/
41
+ .coverage
42
+ .coverage.*
43
+ .cache
44
+ nosetests.xml
45
+ coverage.xml
46
+ junit.xml
47
+ *.cover
48
+ *.py,cover
49
+ .hypothesis/
50
+ .pytest_cache/
51
+ cover/
52
+
53
+ # Translations
54
+ *.mo
55
+ *.pot
56
+
57
+ # Sphinx documentation
58
+ docs/_build/
59
+
60
+ # Jupyter Notebook
61
+ .ipynb_checkpoints
62
+
63
+ # IPython
64
+ profile_default/
65
+ ipython_config.py
66
+
67
+ # pyenv
68
+ .python-version
69
+
70
+ # uv
71
+ .venv
72
+ uv.lock
73
+
74
+ # ruff
75
+ .ruff_cache/
76
+
77
+ # mypy
78
+ .mypy_cache/
79
+ .dmypy.json
80
+ dmypy.json
81
+
82
+ # editors
83
+ .vscode/
84
+ .idea/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Patrick Garrett
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,174 @@
1
+ Metadata-Version: 2.4
2
+ Name: fastatacular
3
+ Version: 0.1.0
4
+ Summary: A pure-Python library for reading and writing FASTA sequence files.
5
+ Project-URL: Repository, https://github.com/tacular-omics/fastatacular
6
+ Project-URL: Issues, https://github.com/tacular-omics/fastatacular/issues
7
+ Author-email: Patrick Garrett <pgarrett@scripps.edu>
8
+ Maintainer-email: Patrick Garrett <pgarrett@scripps.edu>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: bioinformatics,fasta,genomics,proteomics,sequence
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.12
21
+ Description-Content-Type: text/markdown
22
+
23
+ # fastatacular
24
+
25
+ [![Python Package](https://github.com/tacular-omics/fastatacular/actions/workflows/python-package.yml/badge.svg)](https://github.com/tacular-omics/fastatacular/actions/workflows/python-package.yml)
26
+ [![License](https://img.shields.io/github/license/tacular-omics/fastatacular)](LICENSE)
27
+
28
+ Pure-Python library for reading and writing [FASTA](https://en.wikipedia.org/wiki/FASTA_format) sequence files, with optional parsing of UniProt-style description keys (`OS=`, `OX=`, `GN=`, `PE=`, `SV=`) and pipe-delimited identifiers (`sp|P12345|EX_HUMAN`, `gi|12345|ref|NP_000001.1|`).
29
+
30
+ It's the plain-FASTA companion to [pefftacular](https://github.com/tacular-omics/pefftacular) and ships with the same `read_*` / `*Reader` / `write_*` shape.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pip install fastatacular
36
+ ```
37
+
38
+ Dev install:
39
+
40
+ ```bash
41
+ just install
42
+ ```
43
+
44
+ ## Quick start
45
+
46
+ **read_fasta** — load everything into memory at once:
47
+
48
+ ```python
49
+ from fastatacular import read_fasta
50
+
51
+ entries = read_fasta("proteins.fasta")
52
+ for entry in entries:
53
+ print(entry.identifier, len(entry.sequence))
54
+ ```
55
+
56
+ **FastaReader** — iterate lazily without loading the full file:
57
+
58
+ ```python
59
+ from fastatacular import FastaReader
60
+
61
+ with FastaReader("proteins.fasta") as reader:
62
+ for entry in reader:
63
+ process(entry)
64
+ ```
65
+
66
+ ## Data model
67
+
68
+ Each entry is a `SequenceEntry`:
69
+
70
+ | Field | Type | Description |
71
+ |---|---|---|
72
+ | `identifier` | `str` | Token immediately after `>` (e.g. `sp|P12345|EX_HUMAN`) |
73
+ | `sequence` | `str` | Concatenated sequence with whitespace stripped |
74
+ | `prefix` | `str \| None` | Database prefix (`sp`, `tr`, `gi`, ...) when the id is pipe-delimited |
75
+ | `accession` | `str \| None` | First pipe field (e.g. `P12345`) |
76
+ | `entry_name` | `str \| None` | Third pipe field on UniProt ids (e.g. `EX_HUMAN`) |
77
+ | `description` | `str \| None` | Free text after the identifier |
78
+ | `pname` | `str \| None` | Protein name (description text, minus `KEY=value` pairs) |
79
+ | `gname` | `str \| None` | Gene name (`GN=`) |
80
+ | `os_name` | `str \| None` | Organism name (`OS=`) |
81
+ | `ncbi_tax_id` | `int \| None` | NCBI taxonomy ID (`OX=`) |
82
+ | `pe` | `int \| None` | Protein existence level (`PE=`) |
83
+ | `sv` | `int \| None` | Sequence version (`SV=`) |
84
+ | `extra` | `dict[str, str]` | Any other `KEY=value` pairs found in the header |
85
+ | `raw_header` | `str` | The original header line (without leading `>`) |
86
+
87
+ ## UniProt-style headers
88
+
89
+ ```python
90
+ from fastatacular import read_fasta
91
+
92
+ [entry] = read_fasta("one.fasta")
93
+ # >sp|P12345|EX_HUMAN Example protein OS=Homo sapiens OX=9606 GN=EXMP PE=1 SV=2
94
+
95
+ entry.prefix # "sp"
96
+ entry.accession # "P12345"
97
+ entry.entry_name # "EX_HUMAN"
98
+ entry.pname # "Example protein"
99
+ entry.os_name # "Homo sapiens"
100
+ entry.ncbi_tax_id # 9606
101
+ entry.gname # "EXMP"
102
+ entry.pe # 1
103
+ entry.sv # 2
104
+ ```
105
+
106
+ Non-standard `KEY=value` pairs are captured in `entry.extra`. Headers with no `KEY=value` tokens leave `description` and `pname` populated and `extra` empty.
107
+
108
+ ## Writing
109
+
110
+ Construct entries and write them out:
111
+
112
+ ```python
113
+ from fastatacular import SequenceEntry, write_fasta
114
+
115
+ entries = [
116
+ SequenceEntry(
117
+ identifier="sp|P12345|EX_HUMAN",
118
+ sequence="MKTIIALSYIFCLVFA",
119
+ pname="Example protein",
120
+ os_name="Homo sapiens",
121
+ ncbi_tax_id=9606,
122
+ gname="EXMP",
123
+ pe=1,
124
+ sv=2,
125
+ ),
126
+ ]
127
+
128
+ write_fasta(entries, "output.fasta")
129
+ ```
130
+
131
+ `dest` accepts a path string, a `pathlib.Path`, or a text-mode file object.
132
+
133
+ Sequence lines wrap at 60 characters by default. Override with `line_width=` (pass `0` to disable wrapping):
134
+
135
+ ```python
136
+ write_fasta(entries, "output.fasta", line_width=80)
137
+ write_fasta(entries, "single-line.fasta", line_width=0)
138
+ ```
139
+
140
+ If `raw_header` is set on an entry (as it is on every entry produced by `read_fasta`), the writer round-trips it verbatim. Otherwise the header is rebuilt from the structured fields.
141
+
142
+ ## Error handling
143
+
144
+ Parse errors raise `FastaParseError`:
145
+
146
+ ```python
147
+ from fastatacular import FastaParseError, read_fasta
148
+
149
+ try:
150
+ entries = read_fasta("malformed.fasta")
151
+ except FastaParseError as e:
152
+ print(e.line) # offending line number
153
+ print(e.context) # surrounding line content
154
+ ```
155
+
156
+ Write errors raise `FastaWriteError`.
157
+
158
+ ## Development
159
+
160
+ ```bash
161
+ just install # install dependencies
162
+ just test # run tests
163
+ just test-v # run tests (verbose)
164
+ just cov # run tests with coverage
165
+ just lint # ruff lint
166
+ just format # ruff format
167
+ just check # lint + type check + test
168
+ just build # build the package
169
+ just clean # remove cache files
170
+ ```
171
+
172
+ ## License
173
+
174
+ [MIT](LICENSE)
@@ -0,0 +1,152 @@
1
+ # fastatacular
2
+
3
+ [![Python Package](https://github.com/tacular-omics/fastatacular/actions/workflows/python-package.yml/badge.svg)](https://github.com/tacular-omics/fastatacular/actions/workflows/python-package.yml)
4
+ [![License](https://img.shields.io/github/license/tacular-omics/fastatacular)](LICENSE)
5
+
6
+ Pure-Python library for reading and writing [FASTA](https://en.wikipedia.org/wiki/FASTA_format) sequence files, with optional parsing of UniProt-style description keys (`OS=`, `OX=`, `GN=`, `PE=`, `SV=`) and pipe-delimited identifiers (`sp|P12345|EX_HUMAN`, `gi|12345|ref|NP_000001.1|`).
7
+
8
+ It's the plain-FASTA companion to [pefftacular](https://github.com/tacular-omics/pefftacular) and ships with the same `read_*` / `*Reader` / `write_*` shape.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pip install fastatacular
14
+ ```
15
+
16
+ Dev install:
17
+
18
+ ```bash
19
+ just install
20
+ ```
21
+
22
+ ## Quick start
23
+
24
+ **read_fasta** — load everything into memory at once:
25
+
26
+ ```python
27
+ from fastatacular import read_fasta
28
+
29
+ entries = read_fasta("proteins.fasta")
30
+ for entry in entries:
31
+ print(entry.identifier, len(entry.sequence))
32
+ ```
33
+
34
+ **FastaReader** — iterate lazily without loading the full file:
35
+
36
+ ```python
37
+ from fastatacular import FastaReader
38
+
39
+ with FastaReader("proteins.fasta") as reader:
40
+ for entry in reader:
41
+ process(entry)
42
+ ```
43
+
44
+ ## Data model
45
+
46
+ Each entry is a `SequenceEntry`:
47
+
48
+ | Field | Type | Description |
49
+ |---|---|---|
50
+ | `identifier` | `str` | Token immediately after `>` (e.g. `sp|P12345|EX_HUMAN`) |
51
+ | `sequence` | `str` | Concatenated sequence with whitespace stripped |
52
+ | `prefix` | `str \| None` | Database prefix (`sp`, `tr`, `gi`, ...) when the id is pipe-delimited |
53
+ | `accession` | `str \| None` | First pipe field (e.g. `P12345`) |
54
+ | `entry_name` | `str \| None` | Third pipe field on UniProt ids (e.g. `EX_HUMAN`) |
55
+ | `description` | `str \| None` | Free text after the identifier |
56
+ | `pname` | `str \| None` | Protein name (description text, minus `KEY=value` pairs) |
57
+ | `gname` | `str \| None` | Gene name (`GN=`) |
58
+ | `os_name` | `str \| None` | Organism name (`OS=`) |
59
+ | `ncbi_tax_id` | `int \| None` | NCBI taxonomy ID (`OX=`) |
60
+ | `pe` | `int \| None` | Protein existence level (`PE=`) |
61
+ | `sv` | `int \| None` | Sequence version (`SV=`) |
62
+ | `extra` | `dict[str, str]` | Any other `KEY=value` pairs found in the header |
63
+ | `raw_header` | `str` | The original header line (without leading `>`) |
64
+
65
+ ## UniProt-style headers
66
+
67
+ ```python
68
+ from fastatacular import read_fasta
69
+
70
+ [entry] = read_fasta("one.fasta")
71
+ # >sp|P12345|EX_HUMAN Example protein OS=Homo sapiens OX=9606 GN=EXMP PE=1 SV=2
72
+
73
+ entry.prefix # "sp"
74
+ entry.accession # "P12345"
75
+ entry.entry_name # "EX_HUMAN"
76
+ entry.pname # "Example protein"
77
+ entry.os_name # "Homo sapiens"
78
+ entry.ncbi_tax_id # 9606
79
+ entry.gname # "EXMP"
80
+ entry.pe # 1
81
+ entry.sv # 2
82
+ ```
83
+
84
+ Non-standard `KEY=value` pairs are captured in `entry.extra`. Headers with no `KEY=value` tokens leave `description` and `pname` populated and `extra` empty.
85
+
86
+ ## Writing
87
+
88
+ Construct entries and write them out:
89
+
90
+ ```python
91
+ from fastatacular import SequenceEntry, write_fasta
92
+
93
+ entries = [
94
+ SequenceEntry(
95
+ identifier="sp|P12345|EX_HUMAN",
96
+ sequence="MKTIIALSYIFCLVFA",
97
+ pname="Example protein",
98
+ os_name="Homo sapiens",
99
+ ncbi_tax_id=9606,
100
+ gname="EXMP",
101
+ pe=1,
102
+ sv=2,
103
+ ),
104
+ ]
105
+
106
+ write_fasta(entries, "output.fasta")
107
+ ```
108
+
109
+ `dest` accepts a path string, a `pathlib.Path`, or a text-mode file object.
110
+
111
+ Sequence lines wrap at 60 characters by default. Override with `line_width=` (pass `0` to disable wrapping):
112
+
113
+ ```python
114
+ write_fasta(entries, "output.fasta", line_width=80)
115
+ write_fasta(entries, "single-line.fasta", line_width=0)
116
+ ```
117
+
118
+ If `raw_header` is set on an entry (as it is on every entry produced by `read_fasta`), the writer round-trips it verbatim. Otherwise the header is rebuilt from the structured fields.
119
+
120
+ ## Error handling
121
+
122
+ Parse errors raise `FastaParseError`:
123
+
124
+ ```python
125
+ from fastatacular import FastaParseError, read_fasta
126
+
127
+ try:
128
+ entries = read_fasta("malformed.fasta")
129
+ except FastaParseError as e:
130
+ print(e.line) # offending line number
131
+ print(e.context) # surrounding line content
132
+ ```
133
+
134
+ Write errors raise `FastaWriteError`.
135
+
136
+ ## Development
137
+
138
+ ```bash
139
+ just install # install dependencies
140
+ just test # run tests
141
+ just test-v # run tests (verbose)
142
+ just cov # run tests with coverage
143
+ just lint # ruff lint
144
+ just format # ruff format
145
+ just check # lint + type check + test
146
+ just build # build the package
147
+ just clean # remove cache files
148
+ ```
149
+
150
+ ## License
151
+
152
+ [MIT](LICENSE)
@@ -0,0 +1,59 @@
1
+ default: lint format check test
2
+
3
+ # Install dependencies
4
+ install:
5
+ uv sync
6
+
7
+ # Run linting checks
8
+ lint:
9
+ uv run ruff check src
10
+
11
+ # Format code
12
+ format:
13
+ uv run ruff check --select I --fix src
14
+ uv run ruff format src
15
+
16
+ # Run ty type checker
17
+ ty:
18
+ uv run ty check src
19
+
20
+ # Run type checking
21
+ check:
22
+ just lint
23
+ just ty
24
+ just test
25
+
26
+ # Run tests
27
+ test:
28
+ uv run pytest tests
29
+
30
+ # Run tests with verbose output
31
+ test-v:
32
+ uv run pytest tests -v
33
+
34
+ # Run tests for a specific file
35
+ test-file FILE:
36
+ uv run pytest {{FILE}} -v
37
+
38
+ # Run tests with coverage (terminal)
39
+ cov:
40
+ uv run pytest tests --cov=src/fastatacular --cov-report=term-missing
41
+
42
+ # Run tests with coverage (XML for Codecov) + JUnit XML for test results
43
+ test-cov:
44
+ uv run pytest tests --cov=src/fastatacular --cov-report=xml --junitxml=junit.xml -o junit_family=legacy
45
+
46
+ # Remove cache and compiled files
47
+ clean:
48
+ find . -type d -name __pycache__ -exec rm -rf {} +
49
+ find . -type d -name .pytest_cache -exec rm -rf {} +
50
+ find . -type d -name .ruff_cache -exec rm -rf {} +
51
+ find . -name "*.pyc" -delete
52
+
53
+ # Build the package
54
+ build:
55
+ uv build
56
+
57
+ # Install dev dependencies (alias for install)
58
+ dev:
59
+ uv sync
@@ -0,0 +1,70 @@
1
+ [project]
2
+ name = "fastatacular"
3
+ version = "0.1.0"
4
+ description = "A pure-Python library for reading and writing FASTA sequence files."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = []
8
+ authors = [
9
+ { name = "Patrick Garrett", email = "pgarrett@scripps.edu" }
10
+ ]
11
+ license = {text = "MIT"}
12
+ keywords = ["fasta", "proteomics", "genomics", "bioinformatics", "sequence"]
13
+ maintainers = [
14
+ { name = "Patrick Garrett", email = "pgarrett@scripps.edu" }
15
+ ]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Science/Research",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Scientific/Engineering :: Bio-Informatics",
24
+ "Typing :: Typed",
25
+ ]
26
+
27
+ [project.urls]
28
+ Repository = "https://github.com/tacular-omics/fastatacular"
29
+ Issues = "https://github.com/tacular-omics/fastatacular/issues"
30
+
31
+ [build-system]
32
+ requires = ["hatchling"]
33
+ build-backend = "hatchling.build"
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/fastatacular"]
37
+
38
+ [tool.uv]
39
+ package = true
40
+ dev-dependencies = [
41
+ "pytest>=9.0.2",
42
+ "pytest-cov>=6.0",
43
+ "ruff>=0.14.11",
44
+ "ty>=0.0.11",
45
+ ]
46
+
47
+ [tool.ruff]
48
+ target-version = "py312"
49
+ line-length = 120
50
+
51
+ [tool.ruff.lint]
52
+ select = [
53
+ "E", # pycodestyle errors
54
+ "W", # pycodestyle warnings
55
+ "F", # Pyflakes
56
+ "I", # isort
57
+ "B", # flake8-bugbear
58
+ "UP", # pyupgrade
59
+ ]
60
+
61
+ [tool.pytest.ini_options]
62
+ testpaths = ["tests"]
63
+
64
+ [tool.coverage.run]
65
+ source = ["src/fastatacular"]
66
+ omit = ["*/__pycache__/*"]
67
+
68
+ [tool.coverage.report]
69
+ show_missing = true
70
+ skip_covered = false
@@ -0,0 +1,15 @@
1
+ """fastatacular — A pure-Python FASTA parsing and writing library."""
2
+
3
+ from fastatacular._models import SequenceEntry
4
+ from fastatacular._parser import FastaReader, read_fasta
5
+ from fastatacular._writer import write_fasta
6
+ from fastatacular.errors import FastaParseError, FastaWriteError
7
+
8
+ __all__ = [
9
+ "FastaParseError",
10
+ "FastaReader",
11
+ "FastaWriteError",
12
+ "SequenceEntry",
13
+ "read_fasta",
14
+ "write_fasta",
15
+ ]
@@ -0,0 +1,42 @@
1
+ """Frozen dataclass models for FASTA file structures."""
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+
6
+ @dataclass(frozen=True, slots=True)
7
+ class SequenceEntry:
8
+ """A single sequence entry in a FASTA file.
9
+
10
+ Always populated:
11
+ identifier: the token immediately after ``>`` (before any whitespace).
12
+ sequence: the concatenated sequence with whitespace stripped.
13
+
14
+ Parsed from common header conventions when available:
15
+ prefix / accession / entry_name:
16
+ From UniProt-style ``db|ACCESSION|ENTRY_NAME`` identifiers, or
17
+ NCBI-style ``db|ID|...`` identifiers.
18
+ description: free text after the identifier, before any ``KEY=value``.
19
+ pname: protein name (the description text minus UniProt keys).
20
+ gname: gene name (``GN=``).
21
+ os_name: organism name (``OS=``).
22
+ ncbi_tax_id: NCBI taxonomy ID (``OX=``).
23
+ pe: protein existence level (``PE=``).
24
+ sv: sequence version (``SV=``).
25
+ extra: any other ``KEY=value`` pairs found in the header.
26
+ raw_header: the original header line text (without the leading ``>``).
27
+ """
28
+
29
+ identifier: str
30
+ sequence: str
31
+ prefix: str | None = None
32
+ accession: str | None = None
33
+ entry_name: str | None = None
34
+ description: str | None = None
35
+ pname: str | None = None
36
+ gname: str | None = None
37
+ os_name: str | None = None
38
+ ncbi_tax_id: int | None = None
39
+ pe: int | None = None
40
+ sv: int | None = None
41
+ extra: dict[str, str] = field(default_factory=dict)
42
+ raw_header: str = ""
@@ -0,0 +1,206 @@
1
+ """FASTA file parser — converts text into model objects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Iterator
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from types import TracebackType
10
+ from typing import IO
11
+
12
+ from fastatacular._models import SequenceEntry
13
+ from fastatacular.errors import FastaParseError
14
+
15
+ # Matches ``KEY=value`` pairs in UniProt-style headers. Value runs up to the
16
+ # next ``KEY=`` token or end-of-string, then trailing whitespace is trimmed.
17
+ _KV_PATTERN = re.compile(r"(?P<key>[A-Za-z_][A-Za-z0-9_]*)=(?P<val>.*?)(?=\s+[A-Za-z_][A-Za-z0-9_]*=|$)")
18
+
19
+ # UniProt FASTA identifier: ``db|ACCESSION|ENTRY_NAME`` (e.g. ``sp|P12345|EX_HUMAN``)
20
+ _UNIPROT_ID = re.compile(r"^(?P<prefix>[A-Za-z0-9]+)\|(?P<accession>[^|]+)\|(?P<entry_name>[^|\s]+)$")
21
+
22
+ # NCBI-ish ``db|ID`` or ``db|ID|...`` identifier — accept the leading two fields.
23
+ _PIPE_ID = re.compile(r"^(?P<prefix>[A-Za-z0-9]+)\|(?P<accession>[^|\s]+)(?:\|.*)?$")
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class _ParsedHeader:
28
+ """Mutable scratch space built up while parsing a description line."""
29
+
30
+ identifier: str
31
+ raw_header: str
32
+ prefix: str | None = None
33
+ accession: str | None = None
34
+ entry_name: str | None = None
35
+ description: str | None = None
36
+ pname: str | None = None
37
+ gname: str | None = None
38
+ os_name: str | None = None
39
+ ncbi_tax_id: int | None = None
40
+ pe: int | None = None
41
+ sv: int | None = None
42
+ extra: dict[str, str] = field(default_factory=dict)
43
+
44
+
45
+ def _parse_header_line(line: str, line_no: int) -> _ParsedHeader:
46
+ """Parse a single header line into structured fields."""
47
+ if not line.startswith(">"):
48
+ raise FastaParseError("Header line must start with '>'", line=line_no, context=line)
49
+
50
+ raw = line[1:].rstrip("\r\n")
51
+ stripped = raw.strip()
52
+ if not stripped:
53
+ raise FastaParseError("Empty FASTA header", line=line_no, context=line)
54
+
55
+ identifier, _, rest = stripped.partition(" ")
56
+ rest = rest.strip()
57
+
58
+ header = _ParsedHeader(identifier=identifier, raw_header=raw)
59
+
60
+ if m := _UNIPROT_ID.match(identifier):
61
+ header.prefix = m["prefix"]
62
+ header.accession = m["accession"]
63
+ header.entry_name = m["entry_name"]
64
+ elif m := _PIPE_ID.match(identifier):
65
+ header.prefix = m["prefix"]
66
+ header.accession = m["accession"]
67
+
68
+ if not rest:
69
+ return header
70
+
71
+ first_match_start: int | None = None
72
+ for m in _KV_PATTERN.finditer(rest):
73
+ key = m["key"]
74
+ value = m["val"].strip()
75
+ if first_match_start is None:
76
+ first_match_start = m.start()
77
+ if key == "GN":
78
+ header.gname = value
79
+ elif key == "OS":
80
+ header.os_name = value
81
+ elif key == "OX":
82
+ try:
83
+ header.ncbi_tax_id = int(value)
84
+ except ValueError:
85
+ header.extra[key] = value
86
+ elif key == "PE":
87
+ try:
88
+ header.pe = int(value)
89
+ except ValueError:
90
+ header.extra[key] = value
91
+ elif key == "SV":
92
+ try:
93
+ header.sv = int(value)
94
+ except ValueError:
95
+ header.extra[key] = value
96
+ else:
97
+ header.extra[key] = value
98
+
99
+ header.description = rest
100
+ if first_match_start is None:
101
+ header.pname = rest
102
+ elif first_match_start > 0:
103
+ header.pname = rest[:first_match_start].rstrip() or None
104
+
105
+ return header
106
+
107
+
108
+ def _build_entry(header: _ParsedHeader, seq_chunks: list[str], header_line_no: int) -> SequenceEntry:
109
+ sequence = "".join(chunk for chunk in seq_chunks if chunk)
110
+ if not sequence:
111
+ raise FastaParseError(
112
+ f"Entry {header.identifier!r} has no sequence data",
113
+ line=header_line_no,
114
+ context=header.raw_header,
115
+ )
116
+ return SequenceEntry(
117
+ identifier=header.identifier,
118
+ sequence=sequence,
119
+ prefix=header.prefix,
120
+ accession=header.accession,
121
+ entry_name=header.entry_name,
122
+ description=header.description,
123
+ pname=header.pname,
124
+ gname=header.gname,
125
+ os_name=header.os_name,
126
+ ncbi_tax_id=header.ncbi_tax_id,
127
+ pe=header.pe,
128
+ sv=header.sv,
129
+ extra=header.extra,
130
+ raw_header=header.raw_header,
131
+ )
132
+
133
+
134
+ def _iter_entries(fh: IO[str]) -> Iterator[SequenceEntry]:
135
+ """Yield ``SequenceEntry`` objects from an open text-mode file."""
136
+ header: _ParsedHeader | None = None
137
+ seq_chunks: list[str] = []
138
+ header_line_no: int = 0
139
+
140
+ for line_no, line in enumerate(fh, start=1):
141
+ if not line or line[0] in ("\n", "\r"):
142
+ continue
143
+ if line.startswith(";"):
144
+ # Comment line (NCBI / legacy FASTA convention) — skip.
145
+ continue
146
+ if line.startswith(">"):
147
+ if header is not None:
148
+ yield _build_entry(header, seq_chunks, header_line_no)
149
+ header = _parse_header_line(line, line_no)
150
+ header_line_no = line_no
151
+ seq_chunks = []
152
+ else:
153
+ if header is None:
154
+ raise FastaParseError(
155
+ "Sequence data appears before any '>' header",
156
+ line=line_no,
157
+ context=line.rstrip("\n"),
158
+ )
159
+ seq_chunks.append(line.strip())
160
+
161
+ if header is not None:
162
+ yield _build_entry(header, seq_chunks, header_line_no)
163
+
164
+
165
+ class FastaReader:
166
+ """Iterate over a FASTA file lazily without loading the entire file."""
167
+
168
+ def __init__(self, source: str | Path | IO[str]) -> None:
169
+ self._source = source
170
+ self._fh: IO[str] | None = None
171
+ self._owns_fh = False
172
+
173
+ def __enter__(self) -> FastaReader:
174
+ if isinstance(self._source, (str, Path)):
175
+ self._fh = Path(self._source).open(encoding="utf-8")
176
+ self._owns_fh = True
177
+ else:
178
+ self._fh = self._source
179
+ self._owns_fh = False
180
+ return self
181
+
182
+ def __exit__(
183
+ self,
184
+ exc_type: type[BaseException] | None,
185
+ exc: BaseException | None,
186
+ tb: TracebackType | None,
187
+ ) -> None:
188
+ if self._owns_fh and self._fh is not None:
189
+ self._fh.close()
190
+ self._fh = None
191
+
192
+ def __iter__(self) -> Iterator[SequenceEntry]:
193
+ if self._fh is None:
194
+ raise RuntimeError("FastaReader must be used as a context manager (`with FastaReader(...) as r:`)")
195
+ return _iter_entries(self._fh)
196
+
197
+
198
+ def read_fasta(source: str | Path | IO[str]) -> list[SequenceEntry]:
199
+ """Read an entire FASTA file into a list of ``SequenceEntry`` objects."""
200
+ if isinstance(source, (str, Path)):
201
+ with Path(source).open(encoding="utf-8") as fh:
202
+ return list(_iter_entries(fh))
203
+ return list(_iter_entries(source))
204
+
205
+
206
+ __all__ = ["FastaReader", "read_fasta"]
@@ -0,0 +1,83 @@
1
+ """FASTA file writer — serializes models back to FASTA format."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from pathlib import Path
7
+ from typing import IO
8
+
9
+ from fastatacular._models import SequenceEntry
10
+ from fastatacular.errors import FastaWriteError
11
+
12
+ _SEQ_LINE_WIDTH = 60
13
+
14
+
15
+ def _build_header_line(entry: SequenceEntry) -> str:
16
+ """Reconstruct a FASTA description line for ``entry``.
17
+
18
+ Priority: if ``raw_header`` was preserved during parsing, round-trip it
19
+ exactly. Otherwise rebuild from the structured fields.
20
+ """
21
+ if entry.raw_header:
22
+ return f">{entry.raw_header}"
23
+
24
+ parts: list[str] = [entry.identifier]
25
+ if entry.pname:
26
+ parts.append(entry.pname)
27
+ elif entry.description:
28
+ parts.append(entry.description)
29
+
30
+ if entry.os_name is not None:
31
+ parts.append(f"OS={entry.os_name}")
32
+ if entry.ncbi_tax_id is not None:
33
+ parts.append(f"OX={entry.ncbi_tax_id}")
34
+ if entry.gname is not None:
35
+ parts.append(f"GN={entry.gname}")
36
+ if entry.pe is not None:
37
+ parts.append(f"PE={entry.pe}")
38
+ if entry.sv is not None:
39
+ parts.append(f"SV={entry.sv}")
40
+ for k, v in entry.extra.items():
41
+ parts.append(f"{k}={v}")
42
+
43
+ return ">" + " ".join(parts)
44
+
45
+
46
+ def _write_entry(entry: SequenceEntry, out: IO[str], line_width: int) -> None:
47
+ if not entry.identifier:
48
+ raise FastaWriteError("SequenceEntry has an empty identifier")
49
+ if not entry.sequence:
50
+ raise FastaWriteError(f"SequenceEntry {entry.identifier!r} has an empty sequence")
51
+
52
+ out.write(_build_header_line(entry) + "\n")
53
+
54
+ seq = entry.sequence
55
+ if line_width <= 0:
56
+ out.write(seq + "\n")
57
+ return
58
+ for i in range(0, len(seq), line_width):
59
+ out.write(seq[i : i + line_width] + "\n")
60
+
61
+
62
+ def write_fasta(
63
+ entries: Iterable[SequenceEntry],
64
+ dest: str | Path | IO[str],
65
+ *,
66
+ line_width: int = _SEQ_LINE_WIDTH,
67
+ ) -> None:
68
+ """Write a sequence of ``SequenceEntry`` objects to FASTA.
69
+
70
+ ``dest`` may be a path or an already-opened text-mode file object.
71
+ ``line_width`` controls sequence wrapping; pass ``0`` (or any value ``<= 0``)
72
+ to emit each sequence on a single line.
73
+ """
74
+ if isinstance(dest, (str, Path)):
75
+ with Path(dest).open("w", encoding="utf-8") as fh:
76
+ for entry in entries:
77
+ _write_entry(entry, fh, line_width)
78
+ else:
79
+ for entry in entries:
80
+ _write_entry(entry, dest, line_width)
81
+
82
+
83
+ __all__ = ["write_fasta"]
@@ -0,0 +1,14 @@
1
+ """FASTA-specific error types."""
2
+
3
+
4
+ class FastaParseError(ValueError):
5
+ """Raised when FASTA input cannot be parsed."""
6
+
7
+ def __init__(self, message: str, *, line: int | None = None, context: str | None = None) -> None:
8
+ self.line = line
9
+ self.context = context
10
+ super().__init__(message if line is None else f"Line {line}: {message}")
11
+
12
+
13
+ class FastaWriteError(ValueError):
14
+ """Raised when a model object cannot be serialized to FASTA."""
@@ -0,0 +1,19 @@
1
+ """Smoke test: verify public API is importable."""
2
+
3
+ from fastatacular import (
4
+ FastaParseError,
5
+ FastaReader,
6
+ FastaWriteError,
7
+ SequenceEntry,
8
+ read_fasta,
9
+ write_fasta,
10
+ )
11
+
12
+
13
+ def test_public_api_importable():
14
+ assert FastaParseError is not None
15
+ assert FastaReader is not None
16
+ assert FastaWriteError is not None
17
+ assert SequenceEntry is not None
18
+ assert read_fasta is not None
19
+ assert write_fasta is not None
@@ -0,0 +1,114 @@
1
+ """Tests for the FASTA reader."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+
7
+ import pytest
8
+
9
+ from fastatacular import FastaParseError, FastaReader, read_fasta
10
+
11
+
12
+ def _read_str(text: str):
13
+ return read_fasta(io.StringIO(text))
14
+
15
+
16
+ def test_plain_single_entry():
17
+ entries = _read_str(">myid simple description\nACDEFG\nHIJKLM\n")
18
+ assert len(entries) == 1
19
+ e = entries[0]
20
+ assert e.identifier == "myid"
21
+ assert e.sequence == "ACDEFGHIJKLM"
22
+ assert e.description == "simple description"
23
+ assert e.pname == "simple description"
24
+ assert e.prefix is None
25
+ assert e.accession is None
26
+ assert e.gname is None
27
+ assert e.extra == {}
28
+
29
+
30
+ def test_multiple_entries():
31
+ text = ">a one\nAAA\n>b two\nCCC\nGGG\n>c three\nTTT\n"
32
+ entries = _read_str(text)
33
+ assert [e.identifier for e in entries] == ["a", "b", "c"]
34
+ assert [e.sequence for e in entries] == ["AAA", "CCCGGG", "TTT"]
35
+
36
+
37
+ def test_uniprot_style_header():
38
+ header = ">sp|P12345|EX_HUMAN Example protein OS=Homo sapiens OX=9606 GN=EXMP PE=1 SV=2\nMKTIIALSYIFCLVFA\n"
39
+ [e] = _read_str(header)
40
+ assert e.prefix == "sp"
41
+ assert e.accession == "P12345"
42
+ assert e.entry_name == "EX_HUMAN"
43
+ assert e.pname == "Example protein"
44
+ assert e.os_name == "Homo sapiens"
45
+ assert e.ncbi_tax_id == 9606
46
+ assert e.gname == "EXMP"
47
+ assert e.pe == 1
48
+ assert e.sv == 2
49
+ assert e.sequence == "MKTIIALSYIFCLVFA"
50
+
51
+
52
+ def test_ncbi_style_pipe_id():
53
+ [e] = _read_str(">gi|12345|ref|NP_000001.1| some description\nACDEFG\n")
54
+ assert e.identifier == "gi|12345|ref|NP_000001.1|"
55
+ assert e.prefix == "gi"
56
+ assert e.accession == "12345"
57
+ assert e.entry_name is None
58
+ assert e.description == "some description"
59
+
60
+
61
+ def test_extra_keys_captured():
62
+ [e] = _read_str(">id name part FOO=bar BAZ=qux extra trailing\nA\n")
63
+ assert e.extra == {"FOO": "bar", "BAZ": "qux extra trailing"}
64
+ assert e.pname == "name part"
65
+
66
+
67
+ def test_skips_blank_and_comment_lines():
68
+ text = "\n; this is a comment\n>id description\nACDE\n\nFGHI\n"
69
+ [e] = _read_str(text)
70
+ assert e.sequence == "ACDEFGHI"
71
+
72
+
73
+ def test_sequence_before_header_raises():
74
+ with pytest.raises(FastaParseError) as exc:
75
+ _read_str("ACDEFG\n>id description\nACDE\n")
76
+ assert exc.value.line == 1
77
+
78
+
79
+ def test_empty_sequence_raises():
80
+ with pytest.raises(FastaParseError):
81
+ _read_str(">id description\n>next other\nACDE\n")
82
+
83
+
84
+ def test_empty_header_raises():
85
+ with pytest.raises(FastaParseError):
86
+ _read_str("> \nACDE\n")
87
+
88
+
89
+ def test_lazy_reader_context_manager(tmp_path):
90
+ p = tmp_path / "x.fasta"
91
+ p.write_text(">a x\nAAA\n>b y\nCCC\n")
92
+ with FastaReader(p) as reader:
93
+ ids = [e.identifier for e in reader]
94
+ assert ids == ["a", "b"]
95
+
96
+
97
+ def test_lazy_reader_requires_context_manager():
98
+ reader = FastaReader(io.StringIO(">a x\nAAA\n"))
99
+ with pytest.raises(RuntimeError):
100
+ next(iter(reader))
101
+
102
+
103
+ def test_header_with_no_description():
104
+ [e] = _read_str(">justanid\nACDE\n")
105
+ assert e.identifier == "justanid"
106
+ assert e.description is None
107
+ assert e.pname is None
108
+ assert e.sequence == "ACDE"
109
+
110
+
111
+ def test_invalid_ox_falls_back_to_extra():
112
+ [e] = _read_str(">id name OX=notanumber\nACDE\n")
113
+ assert e.ncbi_tax_id is None
114
+ assert e.extra == {"OX": "notanumber"}
@@ -0,0 +1,43 @@
1
+ """Round-trip tests: parse then re-emit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+
7
+ from fastatacular import read_fasta, write_fasta
8
+
9
+
10
+ def test_roundtrip_preserves_sequences_and_ids():
11
+ src = (
12
+ ">sp|P12345|EX_HUMAN Example protein OS=Homo sapiens OX=9606 GN=EXMP PE=1 SV=2\n"
13
+ "MKTIIALSYIFCLVFA\n"
14
+ "ACDEFGHIKLMNPQRS\n"
15
+ ">tr|Q99999|UNK Second entry OS=Mus musculus OX=10090\n"
16
+ "MAGICSEQ\n"
17
+ )
18
+ entries = read_fasta(io.StringIO(src))
19
+ buf = io.StringIO()
20
+ write_fasta(entries, buf)
21
+ rebuilt = read_fasta(io.StringIO(buf.getvalue()))
22
+
23
+ assert len(rebuilt) == len(entries) == 2
24
+ for original, after in zip(entries, rebuilt, strict=True):
25
+ assert original.identifier == after.identifier
26
+ assert original.sequence == after.sequence
27
+ assert original.accession == after.accession
28
+ assert original.gname == after.gname
29
+ assert original.ncbi_tax_id == after.ncbi_tax_id
30
+ assert original.pe == after.pe
31
+ assert original.sv == after.sv
32
+
33
+
34
+ def test_roundtrip_via_file(tmp_path):
35
+ src = ">id desc\nACDEFG\n>other thing\nHHHH\n"
36
+ entries = read_fasta(io.StringIO(src))
37
+
38
+ p = tmp_path / "out.fasta"
39
+ write_fasta(entries, p)
40
+
41
+ rebuilt = read_fasta(p)
42
+ assert [e.identifier for e in rebuilt] == ["id", "other"]
43
+ assert [e.sequence for e in rebuilt] == ["ACDEFG", "HHHH"]
@@ -0,0 +1,90 @@
1
+ """Tests for the FASTA writer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+
7
+ import pytest
8
+
9
+ from fastatacular import FastaWriteError, SequenceEntry, write_fasta
10
+
11
+
12
+ def _write_str(entries, **kw) -> str:
13
+ buf = io.StringIO()
14
+ write_fasta(entries, buf, **kw)
15
+ return buf.getvalue()
16
+
17
+
18
+ def test_write_minimal_entry():
19
+ entry = SequenceEntry(identifier="foo", sequence="ACDEFG")
20
+ out = _write_str([entry])
21
+ assert out == ">foo\nACDEFG\n"
22
+
23
+
24
+ def test_write_with_pname_and_keys():
25
+ entry = SequenceEntry(
26
+ identifier="sp|P12345|EX_HUMAN",
27
+ sequence="MKTIIALSYIFCLVFA",
28
+ pname="Example protein",
29
+ os_name="Homo sapiens",
30
+ ncbi_tax_id=9606,
31
+ gname="EXMP",
32
+ pe=1,
33
+ sv=2,
34
+ )
35
+ out = _write_str([entry])
36
+ assert out.startswith(">sp|P12345|EX_HUMAN Example protein OS=Homo sapiens OX=9606 GN=EXMP PE=1 SV=2\n")
37
+ assert "MKTIIALSYIFCLVFA\n" in out
38
+
39
+
40
+ def test_write_wraps_at_line_width():
41
+ entry = SequenceEntry(identifier="x", sequence="A" * 130)
42
+ out = _write_str([entry])
43
+ seq_lines = out.splitlines()[1:]
44
+ assert all(len(line) <= 60 for line in seq_lines)
45
+ assert sum(len(line) for line in seq_lines) == 130
46
+
47
+
48
+ def test_write_custom_line_width():
49
+ entry = SequenceEntry(identifier="x", sequence="ACDEFGHIJK")
50
+ out = _write_str([entry], line_width=4)
51
+ assert out == ">x\nACDE\nFGHI\nJK\n"
52
+
53
+
54
+ def test_write_no_wrap_when_line_width_zero():
55
+ entry = SequenceEntry(identifier="x", sequence="A" * 200)
56
+ out = _write_str([entry], line_width=0)
57
+ assert out == ">x\n" + "A" * 200 + "\n"
58
+
59
+
60
+ def test_raw_header_round_trips_exactly():
61
+ entry = SequenceEntry(
62
+ identifier="anything",
63
+ sequence="AAA",
64
+ raw_header="sp|P00001|FOO Original header text OS=Mus OX=10090",
65
+ )
66
+ out = _write_str([entry])
67
+ assert out.startswith(">sp|P00001|FOO Original header text OS=Mus OX=10090\n")
68
+
69
+
70
+ def test_write_empty_identifier_raises():
71
+ entry = SequenceEntry(identifier="", sequence="A")
72
+ with pytest.raises(FastaWriteError):
73
+ _write_str([entry])
74
+
75
+
76
+ def test_write_empty_sequence_raises():
77
+ entry = SequenceEntry(identifier="x", sequence="")
78
+ with pytest.raises(FastaWriteError):
79
+ _write_str([entry])
80
+
81
+
82
+ def test_extra_keys_are_emitted():
83
+ entry = SequenceEntry(
84
+ identifier="x",
85
+ sequence="A",
86
+ pname="name",
87
+ extra={"FOO": "bar"},
88
+ )
89
+ out = _write_str([entry])
90
+ assert out.startswith(">x name FOO=bar\n")