fastasma 0.1.1__py3-none-any.whl
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.
- fastasma/Annotators.py +276 -0
- fastasma/Importers.py +128 -0
- fastasma/Mutators.py +59 -0
- fastasma/Types.py +13 -0
- fastasma/Writers.py +149 -0
- fastasma/__init__.py +4 -0
- fastasma-0.1.1.dist-info/METADATA +83 -0
- fastasma-0.1.1.dist-info/RECORD +11 -0
- fastasma-0.1.1.dist-info/WHEEL +5 -0
- fastasma-0.1.1.dist-info/licenses/LICENSE.txt +21 -0
- fastasma-0.1.1.dist-info/top_level.txt +1 -0
fastasma/Annotators.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
from fastasma.Types import Sequence, SequenceSource
|
|
3
|
+
from typing import Iterator, Callable, Optional
|
|
4
|
+
|
|
5
|
+
class DropAnnotations:
|
|
6
|
+
def __init__(self, source: SequenceSource):
|
|
7
|
+
self._source = source
|
|
8
|
+
|
|
9
|
+
def __iter__(self):
|
|
10
|
+
return self.yield_sequence()
|
|
11
|
+
|
|
12
|
+
def yield_sequence(self) -> Iterator[Sequence]:
|
|
13
|
+
for seq in self._source:
|
|
14
|
+
yield Sequence(header=seq.header, sequence=seq.sequence)
|
|
15
|
+
|
|
16
|
+
class DropAnnotationKeys:
|
|
17
|
+
def __init__(self, source: SequenceSource, keys_to_remove: list[str]):
|
|
18
|
+
self._source = source
|
|
19
|
+
self._keys_to_remove = set(keys_to_remove)
|
|
20
|
+
|
|
21
|
+
def __iter__(self):
|
|
22
|
+
return self.yield_sequence()
|
|
23
|
+
|
|
24
|
+
def yield_sequence(self) -> Iterator[Sequence]:
|
|
25
|
+
for seq in self._source:
|
|
26
|
+
new_annotation = {
|
|
27
|
+
key: value
|
|
28
|
+
for key, value in seq.annotation.items()
|
|
29
|
+
if key not in self._keys_to_remove
|
|
30
|
+
}
|
|
31
|
+
yield Sequence(header=seq.header, sequence=seq.sequence, annotation=new_annotation)
|
|
32
|
+
|
|
33
|
+
class AddTaxonomyFromFilename:
|
|
34
|
+
def __init__(self, source: SequenceSource, key: Callable = lambda x: x, header_formatter: Callable = None):
|
|
35
|
+
self._source = source
|
|
36
|
+
self._fun = key
|
|
37
|
+
self._header_formatter = header_formatter or self.default_header_formatter
|
|
38
|
+
|
|
39
|
+
def __iter__(self):
|
|
40
|
+
return self.yield_sequence()
|
|
41
|
+
|
|
42
|
+
def default_header_formatter(self, seq: Sequence, taxon: str) -> str:
|
|
43
|
+
return f"{seq.header} taxon={taxon}"
|
|
44
|
+
|
|
45
|
+
def yield_sequence(self) -> Iterator[Sequence]:
|
|
46
|
+
for seq in self._source:
|
|
47
|
+
taxon = self._fun(getattr(self._source, "filepath", None))
|
|
48
|
+
new_header = self._header_formatter(seq, taxon)
|
|
49
|
+
yield Sequence(new_header, seq.sequence)
|
|
50
|
+
|
|
51
|
+
class AddTaxidFromName:
|
|
52
|
+
def __init__(self, source: SequenceSource, taxonomy_db: str, organims_field: str = "organism"):
|
|
53
|
+
"""Adds taxid information to Sequence objects from a taxonomy database.
|
|
54
|
+
taxonomy_db schema (SQLite):
|
|
55
|
+
CREATE TABLE names (taxid INTEGER, name TEXT, unique_name TEXT, name_class TEXT);
|
|
56
|
+
Correspondence between sequence headers and taxids: names.name (scientific name) -> names.taxid
|
|
57
|
+
"""
|
|
58
|
+
self._source = source
|
|
59
|
+
self._taxonomy_db = taxonomy_db
|
|
60
|
+
self._organism_field = organims_field
|
|
61
|
+
def __iter__(self):
|
|
62
|
+
return self.yield_sequence()
|
|
63
|
+
|
|
64
|
+
def yield_sequence(self) -> Iterator[Sequence]:
|
|
65
|
+
import sqlite3
|
|
66
|
+
|
|
67
|
+
conn = sqlite3.connect(self._taxonomy_db)
|
|
68
|
+
cursor = conn.cursor()
|
|
69
|
+
|
|
70
|
+
# Loads all names and taxids into a dictionary for quick lookup
|
|
71
|
+
cursor.execute("SELECT name, taxid FROM names")
|
|
72
|
+
taxid_dict = {name: taxid for name, taxid in cursor.fetchall()}
|
|
73
|
+
|
|
74
|
+
for seq in self._source:
|
|
75
|
+
organism_name = seq.annotation.get(self._organism_field, None)
|
|
76
|
+
taxid = taxid_dict.get(organism_name)
|
|
77
|
+
new_annotation = dict(seq.annotation)
|
|
78
|
+
new_annotation["taxid"] = taxid
|
|
79
|
+
yield Sequence(header=seq.header, sequence=seq.sequence, annotation=new_annotation)
|
|
80
|
+
|
|
81
|
+
conn.close()
|
|
82
|
+
|
|
83
|
+
class AddNameFromTaxid:
|
|
84
|
+
def __init__(self, source: SequenceSource, taxonomy_db: str, taxid_field: str = "taxid", name_field: str = "organism"):
|
|
85
|
+
"""Converts taxid information in Sequence objects to scientific names using a taxonomy database.
|
|
86
|
+
taxonomy_db schema (SQLite):
|
|
87
|
+
CREATE TABLE names (taxid INTEGER, name TEXT, unique_name TEXT, name_class TEXT);
|
|
88
|
+
Correspondence between taxids and scientific names: names.taxid -> names.name (scientific name)
|
|
89
|
+
"""
|
|
90
|
+
self._source = source
|
|
91
|
+
self._taxonomy_db = taxonomy_db
|
|
92
|
+
self._taxid_field = taxid_field
|
|
93
|
+
self._name_field = name_field
|
|
94
|
+
|
|
95
|
+
def __iter__(self):
|
|
96
|
+
return self.yield_sequence()
|
|
97
|
+
|
|
98
|
+
def yield_sequence(self) -> Iterator[Sequence]:
|
|
99
|
+
import sqlite3
|
|
100
|
+
|
|
101
|
+
conn = sqlite3.connect(self._taxonomy_db)
|
|
102
|
+
cursor = conn.cursor()
|
|
103
|
+
|
|
104
|
+
# Loads all taxids and names into a dictionary for quick lookup
|
|
105
|
+
cursor.execute("SELECT taxid, name FROM names")
|
|
106
|
+
name_dict = {taxid: name for taxid, name in cursor.fetchall()}
|
|
107
|
+
|
|
108
|
+
for seq in self._source:
|
|
109
|
+
taxid = seq.annotation.get(self._taxid_field, None)
|
|
110
|
+
name = name_dict.get(int(taxid))
|
|
111
|
+
new_annotation = dict(seq.annotation)
|
|
112
|
+
new_annotation[self._name_field] = name
|
|
113
|
+
yield Sequence(header=seq.header, sequence=seq.sequence, annotation=new_annotation)
|
|
114
|
+
|
|
115
|
+
conn.close()
|
|
116
|
+
|
|
117
|
+
class AddTaxonomicRankFromTaxid:
|
|
118
|
+
def __init__(
|
|
119
|
+
self,
|
|
120
|
+
source: SequenceSource,
|
|
121
|
+
taxonomy_db: str,
|
|
122
|
+
taxid_field: str = "taxid",
|
|
123
|
+
rank: str = "species"
|
|
124
|
+
):
|
|
125
|
+
"""Adds taxonomic rank information to Sequence objects from a taxonomy database.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
source: Source that yields Sequence objects
|
|
129
|
+
taxonomy_db: Path to SQLite taxonomy database
|
|
130
|
+
taxid_field: Name of the field containing the taxid in annotations
|
|
131
|
+
rank: Taxonomic rank to retrieve (e.g., 'species', 'genus', 'family')
|
|
132
|
+
|
|
133
|
+
Database schema (SQLite):
|
|
134
|
+
CREATE TABLE nodes (taxid INTEGER PRIMARY KEY, parent INTEGER, rank TEXT);
|
|
135
|
+
CREATE TABLE names (taxid INTEGER, name TEXT, unique_name TEXT, name_class TEXT);
|
|
136
|
+
"""
|
|
137
|
+
self._source = source
|
|
138
|
+
self._taxonomy_db = taxonomy_db
|
|
139
|
+
self._taxid_field = taxid_field
|
|
140
|
+
self._rank = rank
|
|
141
|
+
|
|
142
|
+
# Connection will be opened lazily
|
|
143
|
+
self._conn: Optional[sqlite3.Connection] = None
|
|
144
|
+
self._nodes_dict: dict[int, tuple[Optional[int], str]] = {}
|
|
145
|
+
self._names_cache: dict[int, str] = {}
|
|
146
|
+
|
|
147
|
+
def _ensure_connection(self):
|
|
148
|
+
"""Lazy initialization of database connection and data loading."""
|
|
149
|
+
if self._conn is not None:
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
self._conn = sqlite3.connect(self._taxonomy_db)
|
|
153
|
+
self._cursor = self._conn.cursor()
|
|
154
|
+
|
|
155
|
+
# Preload nodes into memory for fast traversal
|
|
156
|
+
self._cursor.execute("SELECT taxid, parent, rank FROM nodes")
|
|
157
|
+
self._nodes_dict = {
|
|
158
|
+
taxid: (parent, rank)
|
|
159
|
+
for taxid, parent, rank in self._cursor.fetchall()
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
# Preload scientific names into memory
|
|
163
|
+
self._cursor.execute(
|
|
164
|
+
"SELECT taxid, name FROM names WHERE name_class = 'scientific name'"
|
|
165
|
+
)
|
|
166
|
+
self._names_cache = {
|
|
167
|
+
taxid: name
|
|
168
|
+
for taxid, name in self._cursor.fetchall()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
def _get_rank_name(self, taxid: int) -> Optional[str]:
|
|
172
|
+
"""Traverse taxonomy tree to find the requested rank and return its name.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
taxid: Starting taxonomic ID
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
Scientific name of the taxon at the requested rank, or None if not found
|
|
179
|
+
"""
|
|
180
|
+
current_taxid: Optional[int] = taxid
|
|
181
|
+
max_iterations = 1000 # Prevent infinite loops in corrupted data
|
|
182
|
+
iterations = 0
|
|
183
|
+
|
|
184
|
+
while current_taxid is not None and iterations < max_iterations:
|
|
185
|
+
node_info = self._nodes_dict.get(current_taxid)
|
|
186
|
+
if node_info is None:
|
|
187
|
+
# Taxid not found in database
|
|
188
|
+
break
|
|
189
|
+
|
|
190
|
+
parent, rank = node_info
|
|
191
|
+
|
|
192
|
+
if rank == self._rank:
|
|
193
|
+
# Found the requested rank, return the scientific name
|
|
194
|
+
return self._names_cache.get(current_taxid)
|
|
195
|
+
|
|
196
|
+
current_taxid = parent
|
|
197
|
+
iterations += 1
|
|
198
|
+
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
def __enter__(self):
|
|
202
|
+
"""Context manager entry."""
|
|
203
|
+
self._ensure_connection()
|
|
204
|
+
return self
|
|
205
|
+
|
|
206
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
207
|
+
"""Context manager exit - ensures connection is closed."""
|
|
208
|
+
self.close()
|
|
209
|
+
return False
|
|
210
|
+
|
|
211
|
+
def close(self):
|
|
212
|
+
"""Close database connection."""
|
|
213
|
+
if self._conn is not None:
|
|
214
|
+
self._conn.close()
|
|
215
|
+
self._conn = None
|
|
216
|
+
|
|
217
|
+
def __iter__(self):
|
|
218
|
+
return self.yield_sequence()
|
|
219
|
+
|
|
220
|
+
def yield_sequence(self) -> Iterator[Sequence]:
|
|
221
|
+
"""Yield sequences with added taxonomic rank information.
|
|
222
|
+
|
|
223
|
+
Yields:
|
|
224
|
+
Sequence objects with the taxonomic rank added to annotations
|
|
225
|
+
"""
|
|
226
|
+
self._ensure_connection()
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
for seq in self._source:
|
|
230
|
+
rank_name = None
|
|
231
|
+
|
|
232
|
+
# Get taxid from annotations
|
|
233
|
+
taxid_str = seq.annotation.get(self._taxid_field) if seq.annotation else None
|
|
234
|
+
|
|
235
|
+
if taxid_str is not None:
|
|
236
|
+
try:
|
|
237
|
+
taxid = int(taxid_str)
|
|
238
|
+
rank_name = self._get_rank_name(taxid)
|
|
239
|
+
except (ValueError, TypeError):
|
|
240
|
+
# Invalid taxid format, leave rank_name as None
|
|
241
|
+
pass
|
|
242
|
+
|
|
243
|
+
# Create new annotation dict with the rank added
|
|
244
|
+
new_annotation = dict(seq.annotation) if seq.annotation else {}
|
|
245
|
+
new_annotation[self._rank] = rank_name
|
|
246
|
+
|
|
247
|
+
yield Sequence(
|
|
248
|
+
header=seq.header,
|
|
249
|
+
sequence=seq.sequence,
|
|
250
|
+
annotation=new_annotation
|
|
251
|
+
)
|
|
252
|
+
finally:
|
|
253
|
+
# Ensure connection is closed when iteration completes or fails
|
|
254
|
+
self.close()
|
|
255
|
+
|
|
256
|
+
class AddAnnotationToHeader:
|
|
257
|
+
def __init__(self, source: SequenceSource, annotation_key: str, separator: str = "_", position: str = "suffix"):
|
|
258
|
+
self._source = source
|
|
259
|
+
self._annotation_key = annotation_key
|
|
260
|
+
self._separator = separator
|
|
261
|
+
self._position = position
|
|
262
|
+
|
|
263
|
+
def __iter__(self):
|
|
264
|
+
return self.yield_sequence()
|
|
265
|
+
|
|
266
|
+
def yield_sequence(self) -> Iterator[Sequence]:
|
|
267
|
+
for seq in self._source:
|
|
268
|
+
value = seq.annotation.get(self._annotation_key) if seq.annotation else None
|
|
269
|
+
if value is not None:
|
|
270
|
+
if self._position == "prefix":
|
|
271
|
+
new_header = f"{value}{self._separator}{seq.header}"
|
|
272
|
+
else:
|
|
273
|
+
new_header = f"{seq.header}{self._separator}{value}"
|
|
274
|
+
else:
|
|
275
|
+
new_header = seq.header
|
|
276
|
+
yield Sequence(header=new_header, sequence=seq.sequence, annotation=seq.annotation)
|
fastasma/Importers.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
from typing import List, Iterator
|
|
4
|
+
from fastasma.Types import Sequence
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ImportFasta:
|
|
8
|
+
def __init__(self, filepath: str):
|
|
9
|
+
self.filepath = filepath
|
|
10
|
+
self._valid_seq = re.compile("^[A-Za-z*-]+$")
|
|
11
|
+
|
|
12
|
+
def __iter__(self):
|
|
13
|
+
return self.yield_sequence()
|
|
14
|
+
|
|
15
|
+
def _is_header(self, text: str)-> bool:
|
|
16
|
+
return bool(text) and text[0] == ">"
|
|
17
|
+
|
|
18
|
+
def _parse_header(self, line: str)-> str:
|
|
19
|
+
# Returns the name of the sequence without the annotations
|
|
20
|
+
if not line.startswith(">"):
|
|
21
|
+
raise ValueError(f"Invalid FASTA header: {line}")
|
|
22
|
+
return line[1:].split()[0]
|
|
23
|
+
|
|
24
|
+
def _parse_sequence(self, lines: List[str])-> str:
|
|
25
|
+
seq = ''.join(lines).replace(" ", "").replace("\n", "")
|
|
26
|
+
if not self._valid_seq.match(seq):
|
|
27
|
+
raise ValueError(f"Invalid FASTA sequence: {seq}")
|
|
28
|
+
return seq
|
|
29
|
+
|
|
30
|
+
def _parse_annotation(self, line: str) -> dict:
|
|
31
|
+
# Parses annotations in the header line formatted as [key=value] or key=value
|
|
32
|
+
# considering that brackets may contain spaces
|
|
33
|
+
annotations = {}
|
|
34
|
+
parts = re.findall(r'\[.*?\]|[^ \[\]]+', line[1:])
|
|
35
|
+
for part in parts[1:]: # Skip the first part which is the header
|
|
36
|
+
part = part.strip("[]") # Remove brackets if present
|
|
37
|
+
if '=' in part:
|
|
38
|
+
key, value = part.split('=', 1)
|
|
39
|
+
annotations[key] = value
|
|
40
|
+
return annotations
|
|
41
|
+
|
|
42
|
+
def _yield_sequence(self, filepath: str) -> Iterator[Sequence]:
|
|
43
|
+
header: str | None = None
|
|
44
|
+
seq_lines: list[str] = []
|
|
45
|
+
annotations: dict = {}
|
|
46
|
+
with open(filepath, "r") as file:
|
|
47
|
+
for raw in file:
|
|
48
|
+
line = raw.strip()
|
|
49
|
+
if not line:
|
|
50
|
+
continue
|
|
51
|
+
if self._is_header(line):
|
|
52
|
+
if header is not None:
|
|
53
|
+
yield Sequence(
|
|
54
|
+
header = header,
|
|
55
|
+
sequence = self._parse_sequence(seq_lines),
|
|
56
|
+
annotation=annotations
|
|
57
|
+
)
|
|
58
|
+
header = self._parse_header(line)
|
|
59
|
+
annotations = self._parse_annotation(line)
|
|
60
|
+
seq_lines = []
|
|
61
|
+
else:
|
|
62
|
+
seq_lines.append(line)
|
|
63
|
+
if header is not None:
|
|
64
|
+
yield Sequence(
|
|
65
|
+
header = header,
|
|
66
|
+
sequence = self._parse_sequence(seq_lines),
|
|
67
|
+
annotation=annotations
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def yield_sequence(self) -> Iterator[Sequence]:
|
|
71
|
+
for seq in self._yield_sequence(self.filepath):
|
|
72
|
+
yield seq
|
|
73
|
+
|
|
74
|
+
class ImportFastas:
|
|
75
|
+
def __init__(self, filepaths: List[str] | None = None, directory: str | None = None):
|
|
76
|
+
if filepaths is None and directory is None:
|
|
77
|
+
raise ValueError("Either filepaths or directory must be provided.")
|
|
78
|
+
if directory is not None:
|
|
79
|
+
filepaths = [
|
|
80
|
+
os.path.join(directory, f)
|
|
81
|
+
for f in os.listdir(directory)
|
|
82
|
+
if f.endswith(".fasta") or f.endswith(".fa")
|
|
83
|
+
]
|
|
84
|
+
self.filepaths = filepaths
|
|
85
|
+
|
|
86
|
+
def __iter__(self):
|
|
87
|
+
return self.yield_sequence()
|
|
88
|
+
|
|
89
|
+
def yield_sequence(self) -> Iterator[Sequence]:
|
|
90
|
+
for path in self.filepaths:
|
|
91
|
+
fasta = ImportFasta(path)
|
|
92
|
+
self.filepath = path # For use in AddTaxonomyFromFilename
|
|
93
|
+
for seq in fasta.yield_sequence():
|
|
94
|
+
yield seq
|
|
95
|
+
|
|
96
|
+
class ImportTSV:
|
|
97
|
+
def __init__(self, filepath: str, header_idx: int = 0, seq_idx: int = 1, annotation_idx: dict | None = None, header: bool = True):
|
|
98
|
+
"""Parses a TSV file to yield Sequence objects.
|
|
99
|
+
Args:
|
|
100
|
+
filepath (str): Path to the TSV file.
|
|
101
|
+
header_idx (int): Column index for the header.
|
|
102
|
+
seq_idx (int): Column index for the sequence.
|
|
103
|
+
annotation_idx (dict | None): Optional dictionary mapping annotation names to column indices.
|
|
104
|
+
header (bool): Whether the TSV file has a header row to skip.
|
|
105
|
+
"""
|
|
106
|
+
self.filepath = filepath
|
|
107
|
+
self.header_idx = header_idx
|
|
108
|
+
self.seq_idx = seq_idx
|
|
109
|
+
self.annotation_idx = annotation_idx or {}
|
|
110
|
+
self.header = header
|
|
111
|
+
def __iter__(self):
|
|
112
|
+
return self.yield_sequence()
|
|
113
|
+
|
|
114
|
+
def yield_sequence(self) -> Iterator[Sequence]:
|
|
115
|
+
with open(self.filepath, "r") as file:
|
|
116
|
+
for i, line in enumerate(file):
|
|
117
|
+
if self.header and i == 0:
|
|
118
|
+
continue
|
|
119
|
+
parts = line.strip().split("\t")
|
|
120
|
+
if not parts or len(parts) <= max(self.header_idx, self.seq_idx, *(self.annotation_idx.values())):
|
|
121
|
+
continue
|
|
122
|
+
header = parts[self.header_idx]
|
|
123
|
+
sequence = parts[self.seq_idx]
|
|
124
|
+
annotation = {
|
|
125
|
+
key: parts[idx]
|
|
126
|
+
for key, idx in self.annotation_idx.items()
|
|
127
|
+
}
|
|
128
|
+
yield Sequence(header=header, sequence=sequence, annotation=annotation)
|
fastasma/Mutators.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from fslab.Types import Sequence, SequenceSource
|
|
2
|
+
from typing import Iterator, Callable, Optional
|
|
3
|
+
from collections import deque
|
|
4
|
+
|
|
5
|
+
class Head:
|
|
6
|
+
def __init__(self, source: SequenceSource, n: int):
|
|
7
|
+
self._source = source
|
|
8
|
+
self._n = n
|
|
9
|
+
|
|
10
|
+
def __iter__(self):
|
|
11
|
+
return self.yield_sequence()
|
|
12
|
+
|
|
13
|
+
def yield_sequence(self) -> Iterator[Sequence]:
|
|
14
|
+
count = 0
|
|
15
|
+
for seq in self._source:
|
|
16
|
+
if count >= self._n:
|
|
17
|
+
break
|
|
18
|
+
yield seq
|
|
19
|
+
count += 1
|
|
20
|
+
|
|
21
|
+
class Tail:
|
|
22
|
+
def __init__(self, source: SequenceSource, n: int):
|
|
23
|
+
self._source = source
|
|
24
|
+
self._n = n
|
|
25
|
+
|
|
26
|
+
def __iter__(self):
|
|
27
|
+
return self.yield_sequence()
|
|
28
|
+
|
|
29
|
+
def yield_sequence(self) -> Iterator[Sequence]:
|
|
30
|
+
buffer = deque(maxlen=self._n)
|
|
31
|
+
for seq in self._source:
|
|
32
|
+
buffer.append(seq)
|
|
33
|
+
for seq in buffer:
|
|
34
|
+
yield seq
|
|
35
|
+
|
|
36
|
+
class Sample:
|
|
37
|
+
def __init__(self, source: SequenceSource, k: int, seed: Optional[int] = None):
|
|
38
|
+
"""Randomly samples k sequences from the source without unpacking the generator into memory.
|
|
39
|
+
Uses reservoir sampling algorithm.
|
|
40
|
+
"""
|
|
41
|
+
import random
|
|
42
|
+
self._source = source
|
|
43
|
+
self._k = k
|
|
44
|
+
self._random = random.Random(seed)
|
|
45
|
+
|
|
46
|
+
def __iter__(self):
|
|
47
|
+
return self.yield_sequence()
|
|
48
|
+
|
|
49
|
+
def yield_sequence(self) -> Iterator[Sequence]:
|
|
50
|
+
reservoir = []
|
|
51
|
+
for i, seq in enumerate(self._source):
|
|
52
|
+
if i < self._k:
|
|
53
|
+
reservoir.append(seq)
|
|
54
|
+
else:
|
|
55
|
+
j = self._random.randint(0, i)
|
|
56
|
+
if j < self._k:
|
|
57
|
+
reservoir[j] = seq
|
|
58
|
+
for seq in reservoir:
|
|
59
|
+
yield seq
|
fastasma/Types.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from typing import Iterator, Protocol
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class Sequence:
|
|
6
|
+
header: str
|
|
7
|
+
sequence: str
|
|
8
|
+
annotation: dict = None
|
|
9
|
+
|
|
10
|
+
class SequenceSource(Protocol):
|
|
11
|
+
def yield_sequence(self) -> Iterator[Sequence]:
|
|
12
|
+
...
|
|
13
|
+
|
fastasma/Writers.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
from fastasma.Types import Sequence, SequenceSource
|
|
2
|
+
from typing import List, Iterator, Callable
|
|
3
|
+
|
|
4
|
+
class SeqSourceToString:
|
|
5
|
+
def __init__(self, source: SequenceSource, wrap: int = 80, annotaton_transform: Callable = None):
|
|
6
|
+
self._source = source
|
|
7
|
+
self._wrap = wrap
|
|
8
|
+
|
|
9
|
+
def __iter__(self):
|
|
10
|
+
return self._sequence_to_str()
|
|
11
|
+
|
|
12
|
+
def annotation_transform(self, seq: Sequence) -> str:
|
|
13
|
+
return ' '.join(f"[{key}={value}]" for key, value in seq.annotation.items()) or ""
|
|
14
|
+
|
|
15
|
+
def _format_header(self, seq: Sequence) -> str:
|
|
16
|
+
annotation = self.annotation_transform(seq)
|
|
17
|
+
if annotation:
|
|
18
|
+
return f">{seq.header} {annotation}\n"
|
|
19
|
+
else:
|
|
20
|
+
return f">{seq.header}\n"
|
|
21
|
+
|
|
22
|
+
def _sequence_to_str(self)-> Iterator[str]:
|
|
23
|
+
for seq in self._source.yield_sequence():
|
|
24
|
+
header_line = self._format_header(seq)
|
|
25
|
+
seq_lines = [
|
|
26
|
+
seq.sequence[i:i+self._wrap] + "\n"
|
|
27
|
+
for i in range(0, len(seq.sequence), self._wrap)
|
|
28
|
+
]
|
|
29
|
+
yield header_line + ''.join(seq_lines)
|
|
30
|
+
|
|
31
|
+
def yield_sequence(self) -> Iterator[str]:
|
|
32
|
+
return self._sequence_to_str()
|
|
33
|
+
|
|
34
|
+
class WriteFasta:
|
|
35
|
+
def __init__(self, source: SequenceSource, output_path: str, wrap: int = 80):
|
|
36
|
+
self._source = source
|
|
37
|
+
self._output_path = output_path
|
|
38
|
+
self._wrap = wrap
|
|
39
|
+
self.write()
|
|
40
|
+
|
|
41
|
+
def write(self):
|
|
42
|
+
with open(self._output_path, "w") as outfile:
|
|
43
|
+
fasta_str_source = SeqSourceToString(self._source, wrap=self._wrap)
|
|
44
|
+
for record_str in fasta_str_source:
|
|
45
|
+
outfile.write(record_str)
|
|
46
|
+
|
|
47
|
+
class WriteTSV:
|
|
48
|
+
def __init__(self, source: SequenceSource, output_path: str, sep: str = "\t"):
|
|
49
|
+
"""Converts Sequence objects to a TSV file, using annotations as columns, plus header and sequence."""
|
|
50
|
+
self._source = source
|
|
51
|
+
self._output_path = output_path
|
|
52
|
+
self._sep = sep
|
|
53
|
+
self.write()
|
|
54
|
+
|
|
55
|
+
def _get_columns(self) -> List[str]:
|
|
56
|
+
columns = set()
|
|
57
|
+
for seq in self._source:
|
|
58
|
+
columns.update(seq.annotation.keys())
|
|
59
|
+
return ["header", "sequence"] + sorted(columns)
|
|
60
|
+
|
|
61
|
+
def write(self):
|
|
62
|
+
columns = self._get_columns()
|
|
63
|
+
with open(self._output_path, "w") as outfile:
|
|
64
|
+
outfile.write(self._sep.join(columns) + "\n")
|
|
65
|
+
for seq in self._source:
|
|
66
|
+
row = [
|
|
67
|
+
seq.header,
|
|
68
|
+
seq.sequence
|
|
69
|
+
] + [
|
|
70
|
+
str(seq.annotation.get(col, "")) for col in columns[2:]
|
|
71
|
+
]
|
|
72
|
+
outfile.write(self._sep.join(row) + "\n")
|
|
73
|
+
|
|
74
|
+
class WriteDB:
|
|
75
|
+
def __init__(self, source: SequenceSource, db_path: str):
|
|
76
|
+
"""Writes Sequence objects to a SQLite database, using annotations as columns, plus header and sequence.
|
|
77
|
+
All the annotations are used as foreign keys to a separate annotations table. Annotations are stored as follows:
|
|
78
|
+
sequence table:
|
|
79
|
+
CREATE TABLE sequences (sequence_id INTEGER PRIMARY KEY, sequence_header TEXT, sequence_sequence TEXT);
|
|
80
|
+
annotation table:
|
|
81
|
+
CREATE TABLE annotations (annotation_id INTEGER PRIMARY KEY, annotation_name TEXT);
|
|
82
|
+
annotation_data table:
|
|
83
|
+
CREATE TABLE annotation_data (annotation_data_id INTEGER PRIMARY KEY, annotation_id INTEGER, annotation_data_value TEXT, sequence_id INTEGER,
|
|
84
|
+
FOREIGN KEY(annotation_id) REFERENCES annotations(annotation_id),
|
|
85
|
+
FOREIGN KEY(sequence_id) REFERENCES sequences(sequence_id));
|
|
86
|
+
"""
|
|
87
|
+
self._source = source
|
|
88
|
+
self._db_path = db_path
|
|
89
|
+
self.write()
|
|
90
|
+
|
|
91
|
+
def write(self):
|
|
92
|
+
import sqlite3
|
|
93
|
+
|
|
94
|
+
conn = sqlite3.connect(self._db_path)
|
|
95
|
+
cursor = conn.cursor()
|
|
96
|
+
|
|
97
|
+
# Create tables
|
|
98
|
+
cursor.execute("""
|
|
99
|
+
CREATE TABLE IF NOT EXISTS sequences (
|
|
100
|
+
sequence_id INTEGER PRIMARY KEY,
|
|
101
|
+
sequence_header TEXT,
|
|
102
|
+
sequence_sequence TEXT
|
|
103
|
+
)
|
|
104
|
+
""")
|
|
105
|
+
cursor.execute("""
|
|
106
|
+
CREATE TABLE IF NOT EXISTS annotations (
|
|
107
|
+
annotation_id INTEGER PRIMARY KEY,
|
|
108
|
+
annotation_name TEXT UNIQUE
|
|
109
|
+
)
|
|
110
|
+
""")
|
|
111
|
+
cursor.execute("""
|
|
112
|
+
CREATE TABLE IF NOT EXISTS annotation_data (
|
|
113
|
+
annotation_data_id INTEGER PRIMARY KEY,
|
|
114
|
+
annotation_id INTEGER,
|
|
115
|
+
annotation_data_value TEXT,
|
|
116
|
+
sequence_id INTEGER,
|
|
117
|
+
FOREIGN KEY(annotation_id) REFERENCES annotations(annotation_id),
|
|
118
|
+
FOREIGN KEY(sequence_id) REFERENCES sequences(sequence_id)
|
|
119
|
+
)
|
|
120
|
+
""")
|
|
121
|
+
|
|
122
|
+
# Insert data
|
|
123
|
+
for seq in self._source:
|
|
124
|
+
cursor.execute(
|
|
125
|
+
"INSERT INTO sequences (sequence_header, sequence_sequence) VALUES (?, ?)",
|
|
126
|
+
(seq.header, seq.sequence)
|
|
127
|
+
)
|
|
128
|
+
sequence_id = cursor.lastrowid
|
|
129
|
+
|
|
130
|
+
for key, value in seq.annotation.items():
|
|
131
|
+
cursor.execute(
|
|
132
|
+
"INSERT OR IGNORE INTO annotations (annotation_name) VALUES (?)",
|
|
133
|
+
(key,)
|
|
134
|
+
)
|
|
135
|
+
cursor.execute(
|
|
136
|
+
"SELECT annotation_id FROM annotations WHERE annotation_name = ?",
|
|
137
|
+
(key,)
|
|
138
|
+
)
|
|
139
|
+
annotation_id = cursor.fetchone()[0]
|
|
140
|
+
|
|
141
|
+
cursor.execute(
|
|
142
|
+
"INSERT INTO annotation_data (annotation_id, annotation_data_value, sequence_id) VALUES (?, ?, ?)",
|
|
143
|
+
(annotation_id, value, sequence_id)
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
conn.commit()
|
|
147
|
+
conn.close()
|
|
148
|
+
|
|
149
|
+
|
fastasma/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
from fastasma.Importers import ImportFasta, ImportFastas, ImportTSV
|
|
2
|
+
from fastasma.Annotators import DropAnnotations, DropAnnotationKeys, AddTaxonomyFromFilename, AddTaxidFromName, AddNameFromTaxid, AddTaxonomicRankFromTaxid, AddAnnotationToHeader
|
|
3
|
+
from fastasma.Writers import WriteFasta, WriteTSV, WriteDB
|
|
4
|
+
from fastasma.Mutators import Head, Tail, Sample
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fastasma
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: A collection of tools for working with FASTA files.
|
|
5
|
+
Author-email: Daniel Pérez-Rodríguez <daniel.perez.rodriguez@uvigo.es>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Daniel Pérez Rodríguez
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
Requires-Python: >=3.8
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
License-File: LICENSE.txt
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# fastasma
|
|
33
|
+
|
|
34
|
+
A collection of tools for working with FASTA files.
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install fastasma
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
import fastasma
|
|
46
|
+
|
|
47
|
+
# Import a FASTA file
|
|
48
|
+
source = fstools.ImportFasta("sequences.fasta")
|
|
49
|
+
|
|
50
|
+
# Annotate sequences
|
|
51
|
+
annotated = fstools.AddAnnotationToHeader(
|
|
52
|
+
source,
|
|
53
|
+
annotation_key="organism",
|
|
54
|
+
separator="_",
|
|
55
|
+
position="suffix"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Write output
|
|
59
|
+
fstools.WriteFasta(annotated, output_path="output.fasta")
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Pipeline example
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
import fastasma
|
|
66
|
+
|
|
67
|
+
# Multiple steps can be chained
|
|
68
|
+
source = fstools.ImportFasta("input.fasta")
|
|
69
|
+
source = fstools.DropAnnotationKeys(source, keys_to_remove=["length"])
|
|
70
|
+
source = fstools.AddAnnotationToHeader(source, annotation_key="organism", position="suffix")
|
|
71
|
+
source = fstools.Head(source, n=10)
|
|
72
|
+
fstools.WriteFasta(source, output_path="output.fasta")
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Available modules
|
|
76
|
+
|
|
77
|
+
**Importers:** `ImportFasta`, `ImportFastas`, `ImportTSV`
|
|
78
|
+
|
|
79
|
+
**Annotators:** `DropAnnotations`, `DropAnnotationKeys`, `AddTaxonomyFromFilename`, `AddTaxidFromName`, `AddNameFromTaxid`, `AddTaxonomicRankFromTaxid`, `AddAnnotationToHeader`
|
|
80
|
+
|
|
81
|
+
**Mutators:** `Head`, `Tail`, `Sample`
|
|
82
|
+
|
|
83
|
+
**Writers:** `WriteFasta`, `WriteTSV`, `WriteDB`
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
fastasma/Annotators.py,sha256=nRtCUG9eoBTAAGekjmeCoi-ourQ6p9XFdCZnCDAZbbw,10537
|
|
2
|
+
fastasma/Importers.py,sha256=g85Ou5VE8jN6884BeThT8uNayRz7dxYdZjzQrbl08OY,5089
|
|
3
|
+
fastasma/Mutators.py,sha256=pGwFDp6PpeRvCZfhv3LBpISmKswYnpc1TiHGnwNJXD4,1718
|
|
4
|
+
fastasma/Types.py,sha256=LWhPy1u5SMAuF03RWhj_ukKdirNohbO3shpvVcqz1bM,263
|
|
5
|
+
fastasma/Writers.py,sha256=KQeVzIFkCuudn3KjLwjanVct2GCAqKynfpUOLPJ7Vtc,5663
|
|
6
|
+
fastasma/__init__.py,sha256=FRo17sFcLBljWeiMaP75lNLja8KdAbKRhoVdswlU5io,355
|
|
7
|
+
fastasma-0.1.1.dist-info/licenses/LICENSE.txt,sha256=88hCDT3C1ZtV_UNguH5oKZVYyfZfcWmTs4z43Ir--dU,1080
|
|
8
|
+
fastasma-0.1.1.dist-info/METADATA,sha256=sVG41bWwvNCAGLp0gEKlzjNi5ga1COpqfqt8EBZ3cGQ,2735
|
|
9
|
+
fastasma-0.1.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
10
|
+
fastasma-0.1.1.dist-info/top_level.txt,sha256=ciUMyRkhn8ZUEers_6u6kYrmC5vMXOuaW4fPU1XL8vk,9
|
|
11
|
+
fastasma-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Daniel Pérez Rodríguez
|
|
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 @@
|
|
|
1
|
+
fastasma
|