SequenceDot 0.2.0__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.
seqdot/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__="0.2.0"
seqdot/alphabet.py ADDED
@@ -0,0 +1,5 @@
1
+ DNA = set("ACGTN")
2
+
3
+ RNA = set("ACGUN")
4
+
5
+ AA = set("ABCDEFGHIKLMNPQRSTVWXYZ")
seqdot/batch.py ADDED
@@ -0,0 +1,199 @@
1
+ import typer
2
+ import itertools
3
+
4
+ from itertools import combinations
5
+ from concurrent.futures import ProcessPoolExecutor, as_completed
6
+
7
+ from tqdm import tqdm
8
+
9
+ from seqdot.compare import compare_sequences, ensure_index
10
+ from seqdot.utils import make_output_filename
11
+
12
+
13
+ def generate_pairs(sequences, include_self=False):
14
+ """
15
+ Generate sequence comparison pairs.
16
+
17
+ Parameters
18
+ ----------
19
+ sequences : list
20
+ List of sequence dictionaries.
21
+
22
+ include_self : bool
23
+ Include comparisons of sequences against themselves.
24
+
25
+ Returns
26
+ -------
27
+ list
28
+ List of sequence pairs.
29
+ """
30
+
31
+ if include_self:
32
+
33
+ pairs = []
34
+
35
+ for i, seq1 in enumerate(sequences):
36
+ for seq2 in sequences[i:]:
37
+ pairs.append((seq1, seq2))
38
+
39
+ return pairs
40
+
41
+ else:
42
+
43
+ return list(
44
+ combinations(sequences, 2)
45
+ )
46
+
47
+
48
+ def format_pair_name(seq1, seq2, max_length=20):
49
+ """
50
+ Format sequence names for the progress bar.
51
+ """
52
+
53
+ def shorten(name):
54
+ if len(name) <= max_length:
55
+ return name
56
+
57
+ return name[: max_length - 3] + "..."
58
+
59
+ return (
60
+ f"{shorten(seq1['name'])} vs "
61
+ f"{shorten(seq2['name'])}"
62
+ )
63
+
64
+ def compare_pair(
65
+ seq1,
66
+ seq2,
67
+ kmer,
68
+ strand,
69
+ output_dir,
70
+ point_size,
71
+ ):
72
+ """
73
+ Compare a single pair of sequences.
74
+ """
75
+
76
+ output_file = make_output_filename(
77
+ seq1["name"],
78
+ seq2["name"]
79
+ )
80
+
81
+ output_file = output_dir / output_file
82
+
83
+ matches = compare_sequences(
84
+ seq1,
85
+ seq2,
86
+ kmer,
87
+ strand,
88
+ str(output_file),
89
+ point_size
90
+ )
91
+
92
+ return {
93
+ "seq1": seq1["name"],
94
+ "seq2": seq2["name"],
95
+ "matches": matches
96
+ }
97
+
98
+
99
+ def run_all_vs_all(
100
+ sequences,
101
+ kmer,
102
+ strand,
103
+ output_dir,
104
+ point_size=1,
105
+ include_self=False,
106
+ silent=False,
107
+ threads=1,
108
+ thread_mode="auto"
109
+ ):
110
+ """
111
+ Run all-vs-all sequence comparisons.
112
+ """
113
+
114
+ pairs = generate_pairs(
115
+ sequences,
116
+ include_self
117
+ )
118
+
119
+ for seq in sequences:
120
+
121
+ ensure_index(
122
+ seq,
123
+ kmer
124
+ )
125
+
126
+ if not silent:
127
+
128
+ typer.echo("SequenceDot batch comparison")
129
+ typer.echo("-" * 50)
130
+
131
+ typer.echo(f"Found sequences : {len(sequences)}")
132
+ typer.echo(f"Comparison mode : all-vs-all")
133
+ typer.echo(f"Include self : {'yes' if include_self else 'no'}")
134
+ typer.echo(f"Total comparisons : {len(pairs)}")
135
+ typer.echo(f"CPU threads : {threads} ({thread_mode})")
136
+
137
+ typer.echo("-" * 50)
138
+ typer.echo()
139
+
140
+ results = []
141
+
142
+ with ProcessPoolExecutor(
143
+ max_workers=threads
144
+ ) as executor:
145
+
146
+ future_to_pair = {}
147
+
148
+ for seq1, seq2 in pairs:
149
+
150
+ future = executor.submit(
151
+ compare_pair,
152
+ seq1,
153
+ seq2,
154
+ kmer,
155
+ strand,
156
+ output_dir,
157
+ point_size
158
+ )
159
+
160
+ future_to_pair[future] = (seq1, seq2)
161
+
162
+ if not silent:
163
+
164
+ with tqdm(
165
+ total=len(future_to_pair),
166
+ desc="Comparisons",
167
+ unit="comparison"
168
+ ) as pbar:
169
+
170
+ for future in as_completed(future_to_pair):
171
+
172
+ seq1, seq2 = future_to_pair[future]
173
+
174
+ pbar.set_postfix_str(
175
+ format_pair_name(seq1, seq2)
176
+ )
177
+
178
+ results.append(
179
+ future.result()
180
+ )
181
+
182
+ pbar.update(1)
183
+
184
+ else:
185
+
186
+ for future in as_completed(future_to_pair):
187
+
188
+ results.append(
189
+ future.result()
190
+ )
191
+
192
+ if not silent:
193
+
194
+ typer.echo()
195
+ typer.echo("✔ Batch comparison completed")
196
+ typer.echo(f"Number of dotplots created: {len(results)}")
197
+ typer.echo(f"Plots written to: {output_dir}")
198
+
199
+ return results
seqdot/cli.py ADDED
@@ -0,0 +1,287 @@
1
+ import typer
2
+ import os
3
+
4
+ from seqdot import __version__
5
+ from seqdot.compare import compare_sequences
6
+ from seqdot.fasta import read_fasta, read_multi_fasta
7
+ from seqdot.batch import run_all_vs_all
8
+ from seqdot.utils import make_output_filename, check_for_gaps, resolve_threads
9
+ from seqdot.report import write_summary
10
+
11
+ from pathlib import Path
12
+ from enum import Enum
13
+
14
+ class Strand(str, Enum):
15
+ forward = "forward"
16
+ reverse = "reverse"
17
+ both = "both"
18
+
19
+ app = typer.Typer(
20
+ name="SequenceDot",
21
+ context_settings={
22
+ "help_option_names": ["-h", "--help"]
23
+ },
24
+ help="""
25
+ Create k-mer-based dotplots from unaligned sequences
26
+
27
+ Supports:
28
+ - comparison of two FASTA files
29
+ - all-vs-all comparison from a multi-sequence FASTA file
30
+ """
31
+ )
32
+
33
+
34
+ def version_callback(value: bool):
35
+
36
+ if value:
37
+
38
+ typer.echo(f"SequenceDot {__version__}")
39
+ raise typer.Exit()
40
+
41
+
42
+ @app.command()
43
+ def main(
44
+ sequence1: str | None = typer.Argument(
45
+ None,
46
+ help="First fasta file (.fasta, .fa, .fna, optionally .gz)"
47
+ ),
48
+ sequence2: str | None = typer.Argument(
49
+ None,
50
+ help="Second fasta file (.fasta, .fa, .fna, optionally .gz)"
51
+ ),
52
+ input_file: str | None = typer.Option(
53
+ None,
54
+ "--file",
55
+ help="Multiple sequence fasta file for batch comparison (.fasta, .fa, .fna, optionally .gz)"
56
+ ),
57
+ all_vs_all: bool = typer.Option(
58
+ False,
59
+ "--all-vs-all",
60
+ help="Compare every sequence against every other sequence in --file, default does NOT include self (add --include-self)"
61
+ ),
62
+ include_self: bool = typer.Option(
63
+ False,
64
+ "--include-self",
65
+ help="Include self-comparisons in all-vs-all mode"
66
+ ),
67
+ kmer: int = typer.Option(
68
+ 11,
69
+ "--kmer",
70
+ "-k",
71
+ min=1,
72
+ max=100,
73
+ help="Length of k-mer used for matching (1-100)"
74
+ ),
75
+ output: str | None = typer.Option(
76
+ None,
77
+ "--output",
78
+ "-o",
79
+ help="Output file for single comparisons (.png, .pdf, .svg)"
80
+ ),
81
+ output_dir: str | None = typer.Option(
82
+ None,
83
+ "--output-dir",
84
+ "-d",
85
+ help="Directory where output files are written, specifically useful for batch mode"
86
+ ),
87
+ alphabet: str = typer.Option(
88
+ "DNA",
89
+ "--alphabet",
90
+ "-a",
91
+ help="Sequence alphabet: DNA, RNA, or AA"
92
+ ),
93
+ strand: Strand = typer.Option(
94
+ Strand.forward,
95
+ "--strand",
96
+ "-s",
97
+ help="Compare forward strand, reverse complement, or both"
98
+ ),
99
+ point_size: float = typer.Option(
100
+ 1.0,
101
+ "--point-size",
102
+ "-p",
103
+ min=0.1,
104
+ max=100,
105
+ help="Size of dots in the plot (0.1-100)"
106
+ ),
107
+ silent: bool = typer.Option(
108
+ False,
109
+ "--silent",
110
+ "-s",
111
+ help="Suppress progress bar during batch processing"
112
+ ),
113
+ version: bool = typer.Option(
114
+ False,
115
+ "--version",
116
+ "-v",
117
+ callback=version_callback,
118
+ is_eager=True,
119
+ help="Show SequenceDot version and exit"
120
+ ),
121
+ threads: str = typer.Option(
122
+ "auto",
123
+ "--threads",
124
+ "-t",
125
+ help="Number of CPU workers (default: auto)"
126
+ )
127
+ ):
128
+
129
+
130
+ """
131
+ Generate a dotplot from two sequence files.
132
+ """
133
+ if all_vs_all:
134
+
135
+ if input_file is None:
136
+ raise typer.BadParameter(
137
+ "--all-vs-all requires --file"
138
+ )
139
+
140
+ sequences = read_multi_fasta(
141
+ input_file,
142
+ alphabet
143
+ )
144
+
145
+ shortest = min(
146
+ seq["length"]
147
+ for seq in sequences
148
+ )
149
+
150
+ if kmer > shortest:
151
+ raise typer.BadParameter(
152
+ f"k-mer size ({kmer}) is larger than the "
153
+ f"shortest sequence ({shortest} bp)."
154
+ )
155
+
156
+ if output_dir is None:
157
+ output_dir = Path("seqdot_results")
158
+
159
+ output_dir = Path(output_dir)
160
+
161
+ output_dir.mkdir(
162
+ parents=True,
163
+ exist_ok=True
164
+ )
165
+
166
+ try:
167
+ cpu_count = os.cpu_count() or 1
168
+ resolved_threads, thread_mode = resolve_threads(
169
+ threads,
170
+ len(sequences)
171
+ )
172
+
173
+ except ValueError as e:
174
+ raise typer.BadParameter(str(e))
175
+
176
+ typer.echo("\nSequenceDot batch comparison")
177
+ typer.echo("-" * 50)
178
+
179
+ typer.echo(f"Found sequences : {len(sequences)}")
180
+ typer.echo("Comparison mode : all-vs-all")
181
+ typer.echo(f"Include self : {'yes' if include_self else 'no'}")
182
+
183
+ n = len(sequences)
184
+ comparisons = (n * (n - 1) // 2)
185
+ if include_self:
186
+ comparisons += n
187
+
188
+ typer.echo(f"Total comparisons : {comparisons}")
189
+
190
+ if thread_mode == "auto":
191
+ typer.echo(
192
+ f"CPU workers : "
193
+ f"{resolved_threads} "
194
+ f"(auto, {cpu_count} available)"
195
+ )
196
+ else:
197
+ typer.echo(
198
+ f"CPU workers : "
199
+ f"{resolved_threads} "
200
+ "(user)"
201
+ )
202
+
203
+ typer.echo("-" * 50)
204
+
205
+ results = run_all_vs_all(
206
+ sequences=sequences,
207
+ kmer=kmer,
208
+ strand=strand.value,
209
+ output_dir=output_dir,
210
+ point_size=point_size,
211
+ include_self=include_self,
212
+ silent=silent,
213
+ threads=resolved_threads,
214
+ thread_mode=thread_mode
215
+ )
216
+
217
+ write_summary(
218
+ results,
219
+ output_dir
220
+ )
221
+
222
+ typer.echo(
223
+ f"Summary written to {output_dir / 'summary.tsv'}"
224
+ )
225
+
226
+ raise typer.Exit()
227
+
228
+ seq1 = read_fasta(sequence1, alphabet)
229
+ seq2 = read_fasta(sequence2, alphabet)
230
+
231
+ shortest = min(
232
+ seq1["length"],
233
+ seq2["length"]
234
+ )
235
+
236
+ for seq in [seq1, seq2]:
237
+
238
+ if check_for_gaps(seq["sequence"]):
239
+ typer.echo(
240
+ f"Warning: {seq['name']} contains gap characters (-). "
241
+ "SequenceDot is designed for unaligned sequences."
242
+ )
243
+
244
+ if kmer > shortest:
245
+ raise typer.BadParameter(
246
+ f"k-mer size ({kmer}) is larger than the "
247
+ f"shortest sequence ({shortest} bp)"
248
+ )
249
+
250
+ if output is None:
251
+ output = make_output_filename(
252
+ seq1["name"],
253
+ seq2["name"]
254
+ )
255
+
256
+ if output_dir is not None:
257
+ output_dir = Path(output_dir)
258
+
259
+ output_dir.mkdir(
260
+ parents=True,
261
+ exist_ok=True
262
+ )
263
+
264
+ output = str(output_dir / output)
265
+
266
+ typer.echo(
267
+ f"Building k-mer index (k={kmer})..."
268
+ )
269
+
270
+ matches = compare_sequences(
271
+ seq1,
272
+ seq2,
273
+ kmer,
274
+ strand.value,
275
+ output,
276
+ point_size
277
+ )
278
+
279
+ typer.echo(
280
+ f"Found {matches} matching k-mers"
281
+ )
282
+
283
+ typer.echo("Dotplot saved")
284
+
285
+
286
+ if __name__ == "__main__":
287
+ app()
seqdot/compare.py ADDED
@@ -0,0 +1,144 @@
1
+ from seqdot.kmer import build_kmer_index, find_kmer_matches, build_kmer_index
2
+ from seqdot.plot import create_dotplot
3
+ from seqdot.utils import reverse_complement
4
+
5
+
6
+ def add_strand(matches, strand):
7
+ """
8
+ Add strand information to matching coordinates.
9
+
10
+ Converts:
11
+ [(x, y), (x, y)]
12
+
13
+ into:
14
+ [(x, y, strand), (x, y, strand)]
15
+ """
16
+
17
+ return [
18
+ (x, y, strand)
19
+ for x, y in matches
20
+ ]
21
+
22
+
23
+ def ensure_index(sequence, kmer):
24
+ """
25
+ Build a k-mer index once and reuse it.
26
+ """
27
+
28
+ if (
29
+ "kmer_index" not in sequence
30
+ or sequence["kmer_index"] is None
31
+ or sequence.get("kmer_index_k") != kmer
32
+ ):
33
+
34
+ sequence["kmer_index"] = build_kmer_index(
35
+ sequence["sequence"],
36
+ kmer
37
+ )
38
+
39
+ sequence["kmer_index_k"] = kmer
40
+
41
+
42
+ def compare_sequences(
43
+ seq1,
44
+ seq2,
45
+ kmer,
46
+ strand,
47
+ output_file,
48
+ point_size=1,
49
+ ):
50
+ """
51
+ Compare two sequences and create a dotplot.
52
+
53
+ Parameters
54
+ ----------
55
+ seq1 : dict
56
+ First sequence dictionary.
57
+
58
+ seq2 : dict
59
+ Second sequence dictionary.
60
+
61
+ kmer : int
62
+ K-mer size.
63
+
64
+ strand : str
65
+ "forward", "reverse", or "both".
66
+
67
+ output_file : str
68
+ Output filename.
69
+
70
+ point_size : float
71
+ Size of dots in plot.
72
+
73
+ Returns
74
+ -------
75
+ int
76
+ Number of matching k-mers.
77
+ """
78
+
79
+ matches = []
80
+
81
+ # Forward comparison
82
+ if strand in ["forward", "both"]:
83
+
84
+ ensure_index(
85
+ seq1,
86
+ kmer
87
+ )
88
+
89
+ index = seq1["kmer_index"]
90
+
91
+ forward_matches = find_kmer_matches(
92
+ seq2["sequence"],
93
+ index,
94
+ kmer
95
+ )
96
+
97
+ matches.extend(
98
+ add_strand(
99
+ forward_matches,
100
+ "forward"
101
+ )
102
+ )
103
+
104
+
105
+ # Reverse-complement comparison
106
+ if strand in ["reverse", "both"]:
107
+
108
+ reverse_seq2 = reverse_complement(
109
+ seq2["sequence"]
110
+ )
111
+
112
+ ensure_index(
113
+ seq1,
114
+ kmer
115
+ )
116
+
117
+ index = seq1["kmer_index"]
118
+
119
+ reverse_matches = find_kmer_matches(
120
+ reverse_seq2,
121
+ index,
122
+ kmer
123
+ )
124
+
125
+ matches.extend(
126
+ add_strand(
127
+ reverse_matches,
128
+ "reverse"
129
+ )
130
+ )
131
+
132
+ create_dotplot(
133
+ matches,
134
+ seq1["length"],
135
+ seq2["length"],
136
+ kmer,
137
+ name1=seq1["name"],
138
+ name2=seq2["name"],
139
+ output_file=output_file,
140
+ point_size=point_size
141
+ )
142
+
143
+
144
+ return len(matches)