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.
- fastatacular-0.1.0/.github/workflows/python-package.yml +58 -0
- fastatacular-0.1.0/.github/workflows/python-publish.yml +32 -0
- fastatacular-0.1.0/.gitignore +84 -0
- fastatacular-0.1.0/LICENSE +21 -0
- fastatacular-0.1.0/PKG-INFO +174 -0
- fastatacular-0.1.0/README.md +152 -0
- fastatacular-0.1.0/justfile +59 -0
- fastatacular-0.1.0/pyproject.toml +70 -0
- fastatacular-0.1.0/src/fastatacular/__init__.py +15 -0
- fastatacular-0.1.0/src/fastatacular/_models.py +42 -0
- fastatacular-0.1.0/src/fastatacular/_parser.py +206 -0
- fastatacular-0.1.0/src/fastatacular/_writer.py +83 -0
- fastatacular-0.1.0/src/fastatacular/errors.py +14 -0
- fastatacular-0.1.0/tests/test_basic.py +19 -0
- fastatacular-0.1.0/tests/test_reader.py +114 -0
- fastatacular-0.1.0/tests/test_roundtrip.py +43 -0
- fastatacular-0.1.0/tests/test_writer.py +90 -0
|
@@ -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
|
+
[](https://github.com/tacular-omics/fastatacular/actions/workflows/python-package.yml)
|
|
26
|
+
[](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
|
+
[](https://github.com/tacular-omics/fastatacular/actions/workflows/python-package.yml)
|
|
4
|
+
[](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")
|