PGAP2 2.4__tar.gz → 2.4.2__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.
Files changed (91) hide show
  1. {pgap2-2.4 → pgap2-2.4.2/PGAP2.egg-info}/PKG-INFO +1 -1
  2. {pgap2-2.4 → pgap2-2.4.2}/PGAP2.egg-info/SOURCES.txt +1 -0
  3. {pgap2-2.4/PGAP2.egg-info → pgap2-2.4.2}/PKG-INFO +1 -1
  4. {pgap2-2.4 → pgap2-2.4.2}/pgap2/utils/gene_retriever.py +14 -12
  5. {pgap2-2.4 → pgap2-2.4.2}/pyproject.toml +1 -1
  6. pgap2-2.4.2/tests/test_gene_retriever.py +261 -0
  7. {pgap2-2.4 → pgap2-2.4.2}/LICENSE +0 -0
  8. {pgap2-2.4 → pgap2-2.4.2}/MANIFEST.in +0 -0
  9. {pgap2-2.4 → pgap2-2.4.2}/PGAP2.egg-info/dependency_links.txt +0 -0
  10. {pgap2-2.4 → pgap2-2.4.2}/PGAP2.egg-info/entry_points.txt +0 -0
  11. {pgap2-2.4 → pgap2-2.4.2}/PGAP2.egg-info/requires.txt +0 -0
  12. {pgap2-2.4 → pgap2-2.4.2}/PGAP2.egg-info/top_level.txt +0 -0
  13. {pgap2-2.4 → pgap2-2.4.2}/README.md +0 -0
  14. {pgap2-2.4 → pgap2-2.4.2}/pgap2/__init__.py +0 -0
  15. {pgap2-2.4 → pgap2-2.4.2}/pgap2/cli/__init__.py +0 -0
  16. {pgap2-2.4 → pgap2-2.4.2}/pgap2/cli/neighborhood.py +0 -0
  17. {pgap2-2.4 → pgap2-2.4.2}/pgap2/cli/partition.py +0 -0
  18. {pgap2-2.4 → pgap2-2.4.2}/pgap2/cli/partition_add.py +0 -0
  19. {pgap2-2.4 → pgap2-2.4.2}/pgap2/cli/partition_merge.py +0 -0
  20. {pgap2-2.4 → pgap2-2.4.2}/pgap2/cli/partition_minus.py +0 -0
  21. {pgap2-2.4 → pgap2-2.4.2}/pgap2/cli/postprocess.py +0 -0
  22. {pgap2-2.4 → pgap2-2.4.2}/pgap2/cli/preprocess.py +0 -0
  23. {pgap2-2.4 → pgap2-2.4.2}/pgap2/cli/sqlite_cmd.py +0 -0
  24. {pgap2-2.4 → pgap2-2.4.2}/pgap2/dependencies/draw_post_profile.r +0 -0
  25. {pgap2-2.4 → pgap2-2.4.2}/pgap2/dependencies/draw_post_stat.r +0 -0
  26. {pgap2-2.4 → pgap2-2.4.2}/pgap2/dependencies/draw_prep.r +0 -0
  27. {pgap2-2.4 → pgap2-2.4.2}/pgap2/dependencies/maskrc-svg.py +0 -0
  28. {pgap2-2.4 → pgap2-2.4.2}/pgap2/dependencies/run_fastbaps +0 -0
  29. {pgap2-2.4 → pgap2-2.4.2}/pgap2/lib/__init__.py +0 -0
  30. {pgap2-2.4 → pgap2-2.4.2}/pgap2/lib/basic.py +0 -0
  31. {pgap2-2.4 → pgap2-2.4.2}/pgap2/lib/panclust.py +0 -0
  32. {pgap2-2.4 → pgap2-2.4.2}/pgap2/lib/pangenome.py +0 -0
  33. {pgap2-2.4 → pgap2-2.4.2}/pgap2/lib/pangp.py +0 -0
  34. {pgap2-2.4 → pgap2-2.4.2}/pgap2/lib/phylogeny.py +0 -0
  35. {pgap2-2.4 → pgap2-2.4.2}/pgap2/lib/pklcheck.py +0 -0
  36. {pgap2-2.4 → pgap2-2.4.2}/pgap2/lib/species.py +0 -0
  37. {pgap2-2.4 → pgap2-2.4.2}/pgap2/lib/strain.py +0 -0
  38. {pgap2-2.4 → pgap2-2.4.2}/pgap2/lib/synteny.py +0 -0
  39. {pgap2-2.4 → pgap2-2.4.2}/pgap2/lib/tree.py +0 -0
  40. {pgap2-2.4 → pgap2-2.4.2}/pgap2/main.py +0 -0
  41. {pgap2-2.4 → pgap2-2.4.2}/pgap2/models/__init__.py +0 -0
  42. {pgap2-2.4 → pgap2-2.4.2}/pgap2/models/baps.py +0 -0
  43. {pgap2-2.4 → pgap2-2.4.2}/pgap2/models/base.py +0 -0
  44. {pgap2-2.4 → pgap2-2.4.2}/pgap2/models/gene_annotation.py +0 -0
  45. {pgap2-2.4 → pgap2-2.4.2}/pgap2/models/gene_cluster.py +0 -0
  46. {pgap2-2.4 → pgap2-2.4.2}/pgap2/models/gene_neighborhood.py +0 -0
  47. {pgap2-2.4 → pgap2-2.4.2}/pgap2/models/pav.py +0 -0
  48. {pgap2-2.4 → pgap2-2.4.2}/pgap2/models/phylogeny.py +0 -0
  49. {pgap2-2.4 → pgap2-2.4.2}/pgap2/models/preprocess.py +0 -0
  50. {pgap2-2.4 → pgap2-2.4.2}/pgap2/models/project.py +0 -0
  51. {pgap2-2.4 → pgap2-2.4.2}/pgap2/models/project_file.py +0 -0
  52. {pgap2-2.4 → pgap2-2.4.2}/pgap2/models/statistics.py +0 -0
  53. {pgap2-2.4 → pgap2-2.4.2}/pgap2/models/strain.py +0 -0
  54. {pgap2-2.4 → pgap2-2.4.2}/pgap2/models/tajimas_d.py +0 -0
  55. {pgap2-2.4 → pgap2-2.4.2}/pgap2/postprocess/__init__.py +0 -0
  56. {pgap2-2.4 → pgap2-2.4.2}/pgap2/postprocess/baps.py +0 -0
  57. {pgap2-2.4 → pgap2-2.4.2}/pgap2/postprocess/graph.py +0 -0
  58. {pgap2-2.4 → pgap2-2.4.2}/pgap2/postprocess/neighborhood.py +0 -0
  59. {pgap2-2.4 → pgap2-2.4.2}/pgap2/postprocess/profile.py +0 -0
  60. {pgap2-2.4 → pgap2-2.4.2}/pgap2/postprocess/reference_layout.py +0 -0
  61. {pgap2-2.4 → pgap2-2.4.2}/pgap2/postprocess/singletree.py +0 -0
  62. {pgap2-2.4 → pgap2-2.4.2}/pgap2/postprocess/stat.py +0 -0
  63. {pgap2-2.4 → pgap2-2.4.2}/pgap2/postprocess/tajimas_d.py +0 -0
  64. {pgap2-2.4 → pgap2-2.4.2}/pgap2/toy_input/287_91.fa +0 -0
  65. {pgap2-2.4 → pgap2-2.4.2}/pgap2/toy_input/287_91.gff +0 -0
  66. {pgap2-2.4 → pgap2-2.4.2}/pgap2/toy_input/AKU_12601.gff +0 -0
  67. {pgap2-2.4 → pgap2-2.4.2}/pgap2/toy_input/Readme_input.md +0 -0
  68. {pgap2-2.4 → pgap2-2.4.2}/pgap2/toy_input/SC_B67.gbk +0 -0
  69. {pgap2-2.4 → pgap2-2.4.2}/pgap2/utils/__init__.py +0 -0
  70. {pgap2-2.4 → pgap2-2.4.2}/pgap2/utils/alignment.py +0 -0
  71. {pgap2-2.4 → pgap2-2.4.2}/pgap2/utils/arrangement_detector.py +0 -0
  72. {pgap2-2.4 → pgap2-2.4.2}/pgap2/utils/data_loader.py +0 -0
  73. {pgap2-2.4 → pgap2-2.4.2}/pgap2/utils/draw.py +0 -0
  74. {pgap2-2.4 → pgap2-2.4.2}/pgap2/utils/generate_tree.py +0 -0
  75. {pgap2-2.4 → pgap2-2.4.2}/pgap2/utils/graph_utils.py +0 -0
  76. {pgap2-2.4 → pgap2-2.4.2}/pgap2/utils/neighborhood.py +0 -0
  77. {pgap2-2.4 → pgap2-2.4.2}/pgap2/utils/partition.py +0 -0
  78. {pgap2-2.4 → pgap2-2.4.2}/pgap2/utils/partition_add.py +0 -0
  79. {pgap2-2.4 → pgap2-2.4.2}/pgap2/utils/partition_merge.py +0 -0
  80. {pgap2-2.4 → pgap2-2.4.2}/pgap2/utils/partition_minus.py +0 -0
  81. {pgap2-2.4 → pgap2-2.4.2}/pgap2/utils/postprocess.py +0 -0
  82. {pgap2-2.4 → pgap2-2.4.2}/pgap2/utils/preprocess.py +0 -0
  83. {pgap2-2.4 → pgap2-2.4.2}/pgap2/utils/sqlite_builder.py +0 -0
  84. {pgap2-2.4 → pgap2-2.4.2}/pgap2/utils/supply.py +0 -0
  85. {pgap2-2.4 → pgap2-2.4.2}/pgap2/utils/tools.py +0 -0
  86. {pgap2-2.4 → pgap2-2.4.2}/setup.cfg +0 -0
  87. {pgap2-2.4 → pgap2-2.4.2}/tests/test_main.py +0 -0
  88. {pgap2-2.4 → pgap2-2.4.2}/tests/test_pangenome.py +0 -0
  89. {pgap2-2.4 → pgap2-2.4.2}/tests/test_post.py +0 -0
  90. {pgap2-2.4 → pgap2-2.4.2}/tests/test_prep.py +0 -0
  91. {pgap2-2.4 → pgap2-2.4.2}/tests/test_synteny.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PGAP2
3
- Version: 2.4
3
+ Version: 2.4.2
4
4
  Summary: PGAP2: a comprehensive pan-genome analysis pipeline for prokaryotic genomes
5
5
  Author-email: bucongfan <bucf@big.ac.cn>
6
6
  Maintainer-email: bucongfan <bucf@big.ac.cn>
@@ -81,6 +81,7 @@ pgap2/utils/preprocess.py
81
81
  pgap2/utils/sqlite_builder.py
82
82
  pgap2/utils/supply.py
83
83
  pgap2/utils/tools.py
84
+ tests/test_gene_retriever.py
84
85
  tests/test_main.py
85
86
  tests/test_pangenome.py
86
87
  tests/test_post.py
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PGAP2
3
- Version: 2.4
3
+ Version: 2.4.2
4
4
  Summary: PGAP2: a comprehensive pan-genome analysis pipeline for prokaryotic genomes
5
5
  Author-email: bucongfan <bucf@big.ac.cn>
6
6
  Maintainer-email: bucongfan <bucf@big.ac.cn>
@@ -103,7 +103,6 @@ def retrieve_from_paf(fpaf, min_overlap_bp=1):
103
103
  split_list = line.rstrip('\n').split('\t')
104
104
 
105
105
  query = split_list[0]
106
- qlen = int(split_list[1]) * 3
107
106
  strand = 1 if split_list[4] == '+' else -1
108
107
  contig = split_list[5]
109
108
  t_start = int(split_list[7])
@@ -122,8 +121,8 @@ def retrieve_from_paf(fpaf, min_overlap_bp=1):
122
121
  f"PAF line {line.strip()} does not contain AS:i: score, using matches {matches} as fallback."
123
122
  )
124
123
 
125
- identity = round(
126
- qlen / block, 5) if block >= qlen else round(matches / block, 5)
124
+ # Both counts are in nucleotide units in miniprot PAF.
125
+ identity = round(matches / block, 5)
127
126
 
128
127
  rec = dict(q=query, start=t_start, end=t_end,
129
128
  score=score, identity=identity,
@@ -258,7 +257,7 @@ def realign(pg: Pangenome, tree: Tree, outdir, need_realign_root: set):
258
257
  if node in pg.nucl_fa:
259
258
  exitst_file_flag = True
260
259
  nucl_seq = Seq(pg.nucl_fa[node])
261
- fh.write(f'>{node}\n{nucl_seq.translate()}\n')
260
+ fh.write(f'>{node}\n{nucl_seq.translate(table=pg.gcode)}\n')
262
261
  if exitst_file_flag:
263
262
  logger.info('Re-aligning sequences to update the identity network...')
264
263
  edges, notused_edges = realign_parser(outdir=outdir, evalue=pg.evalue, aligner=pg.aligner, mcxdeblast=sfw.mcxdeblast,
@@ -403,16 +402,18 @@ def retrieve_gene_from_genome(G: nx.Graph, pg: Pangenome, tree: Tree):
403
402
  logger.trace(
404
403
  f'Retrieved gene ({loc}) overlaps existing CDS, skip.')
405
404
  continue
406
- before_e = before_s + \
407
- pg.annot[f'{strain}:{contig}:{gene_rank_all.index(before_s)}']['len']*3+3
405
+ # Gene IDs use the contig-wide order, not the strand-specific rank.
406
+ before_gene = f'{strain}:{contig}:{gene_rank_all.index(before_s)}'
407
+ after_gene = f'{strain}:{contig}:{gene_rank_all.index(after_s)}'
408
+ before_e = before_s + pg.annot[before_gene]['len']*3+3
408
409
  if before_e >= start:
409
410
  logger.trace(
410
411
  f'Retrived gene ({loc})) has overlap with exists coding gene, skip it.')
411
412
  continue
412
413
 
413
414
  assumed_gene = f'{strain}:{contig}:{gene_num+j}'
414
- before_node = member2leaf[f'{strain}:{contig}:{gene_rank.index(before_s)}']
415
- after_node = member2leaf[f'{strain}:{contig}:{gene_rank.index(after_s)}']
415
+ before_node = member2leaf[before_gene]
416
+ after_node = member2leaf[after_gene]
416
417
 
417
418
  if not G.has_node(before_node) or not G.has_node(after_node):
418
419
  # this gene has been merged baited by other strain's gene
@@ -451,13 +452,14 @@ def retrieve_gene_from_genome(G: nx.Graph, pg: Pangenome, tree: Tree):
451
452
  start=start, end=end, strand=loc['strand']), type="CDS", id=retrieve_gene_name)
452
453
  seq = feature.extract(strain_seq_dict[contig].seq)
453
454
  with warnings.catch_warnings(record=True) as w:
454
- # only capture BiopythonWarning
455
455
  warnings.simplefilter("always", BiopythonWarning)
456
456
 
457
- prot = seq.translate()
457
+ prot = seq.translate(table=pg.gcode)
458
458
 
459
- # check if any warnings were captured
460
- if len(w) > 0 and issubclass(w[0].category, BiopythonWarning):
459
+ has_translation_warning = any(
460
+ issubclass(warning.category, BiopythonWarning) for warning in w)
461
+ # Internal stops do not raise warnings; a single terminal stop is allowed.
462
+ if has_translation_warning or '*' in prot[:-1]:
461
463
  pseudo_gene_product = f'pseudo|{identity}|{cigar}'
462
464
  tree.pseudo_root_leaf[tree.leaf_root[expect_node]].add(
463
465
  assumed_gene)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "PGAP2"
7
- version = "2.4"
7
+ version = "2.4.2"
8
8
  dynamic = ["readme"]
9
9
  keywords = ["pan-genome", "prokaryotic", "genomes", "pipeline","cluster"]
10
10
 
@@ -0,0 +1,261 @@
1
+ from types import SimpleNamespace
2
+ from unittest.mock import Mock
3
+
4
+ import networkx as nx
5
+ import pytest
6
+ from Bio import SeqIO
7
+ from Bio.Seq import Seq
8
+
9
+ from pgap2.lib.pangenome import Pangenome
10
+ from pgap2.lib.tree import Tree
11
+ from pgap2.utils import gene_retriever
12
+
13
+
14
+ def _paf_line(query, protein_length, matches, block, strand="+", score=50):
15
+ fields = [
16
+ query, protein_length, 0, min(protein_length, block // 3), strand,
17
+ "0", 1200, 200, 200 + block, matches, block, 60, f"AS:i:{score}",
18
+ ]
19
+ return "\t".join(map(str, fields)) + "\n"
20
+
21
+
22
+ @pytest.mark.parametrize(
23
+ "protein_length,matches,block,identity",
24
+ [
25
+ pytest.param(100, 120, 300, 0.4, id="full-length-low-identity"),
26
+ pytest.param(100, 270, 300, 0.9, id="full-length-mismatches"),
27
+ pytest.param(100, 270, 360, 0.75, id="longer-alignment"),
28
+ pytest.param(100, 180, 240, 0.75, id="partial-alignment"),
29
+ pytest.param(100, 300, 300, 1.0, id="perfect-match"),
30
+ pytest.param(100, 198, 297, 0.66667, id="rounding"),
31
+ ],
32
+ )
33
+ def test_retrieve_from_paf_identity(tmp_path, protein_length, matches, block,
34
+ identity):
35
+ paf_file = tmp_path / "hits.paf"
36
+ paf_file.write_text(_paf_line("query", protein_length, matches, block))
37
+
38
+ hits = gene_retriever.retrieve_from_paf(paf_file)
39
+
40
+ assert hits["0"]["query"]["identity"] == identity
41
+
42
+
43
+ @pytest.mark.parametrize("high_identity_first", [False, True])
44
+ def test_retrieve_from_paf_overlap_prefers_identity(tmp_path, high_identity_first):
45
+ lines = [
46
+ _paf_line("low_identity", 100, 120, 300),
47
+ _paf_line("high_identity", 100, 270, 300),
48
+ ]
49
+ if high_identity_first:
50
+ lines.reverse()
51
+ paf_file = tmp_path / "hits.paf"
52
+ paf_file.write_text("".join(lines))
53
+
54
+ hits = gene_retriever.retrieve_from_paf(paf_file)
55
+
56
+ assert set(hits["0"]) == {"high_identity"}
57
+ assert hits["0"]["high_identity"]["identity"] == 0.9
58
+
59
+
60
+ @pytest.mark.parametrize("strand", [1, -1])
61
+ @pytest.mark.parametrize(
62
+ "protein_length,matches,outcome",
63
+ [
64
+ pytest.param(100, 120, "rejected", id="low-identity-filtered"),
65
+ pytest.param(100, 270, "inserted", id="below-orth-not-merged"),
66
+ pytest.param(120, 270, "inserted", id="mixed-strand-neighbors"),
67
+ pytest.param(100, 300, "merged", id="high-identity-merged"),
68
+ ],
69
+ )
70
+ def test_retrieval_identity_and_neighbors(tmp_path, monkeypatch, strand,
71
+ protein_length, matches, outcome):
72
+ expected_node = "1:0:0"
73
+ retrieved_gene = "0:0:4"
74
+ left_node, right_node = "0:0:1", "0:0:3"
75
+ graph = nx.Graph()
76
+ graph.add_node(expected_node, length=protein_length, members={expected_node},
77
+ strains={1}, repre_nodes=[expected_node])
78
+ # The same-strand flanks are genes 1 and 3, not strand-local indices 0 and 1.
79
+ positions = [(0, -strand), (60, strand), (90, -strand), (900, strand)]
80
+ rows = [
81
+ f"{expected_node}\treference\tcontig_1\t[0:360](+)\t{protein_length}"
82
+ "\treference_gene\tgene\tproduct\tATG\tM\n"
83
+ ]
84
+ for index, (start, gene_strand) in enumerate(positions):
85
+ gene = f"0:0:{index}"
86
+ graph.add_node(gene, length=5, members={gene, f"1:0:{index + 1}"},
87
+ strains={0, 1}, repre_nodes=[gene])
88
+ sign = "+" if gene_strand == 1 else "-"
89
+ rows.append(
90
+ f"{gene}\ttarget\tcontig_1\t[{start}:{start + 18}]({sign})"
91
+ f"\t5\tgene_{index}\tgene\tproduct\tATG\tM\n")
92
+ graph.add_edges_from([(expected_node, left_node), (expected_node, right_node)])
93
+
94
+ pg = Pangenome(outdir=str(tmp_path), threads=1, gcode=11, disable=True)
95
+ annot_file = tmp_path / "total.involved_annot.tsv"
96
+ annot_file.write_text("".join(rows))
97
+ pg.total_gene_num = len(rows)
98
+ pg.load_annot_file(str(annot_file))
99
+ pg.reload_annot_file(retrieve=True)
100
+ pg.para_id, pg.orth_id = 0.7, 0.98
101
+ pg.nucl_fa = {}
102
+ pg.prot_file = str(tmp_path / "query-source.fa")
103
+ pg.strain_dict = {0: SimpleNamespace(strain_name="target")}
104
+
105
+ tree = Tree()
106
+ tree.leaf_root[expected_node] = 0
107
+ tree.root_leaf[0].add(expected_node)
108
+ tree.distance_graph.add_node(expected_node)
109
+ tree.raw_distance_graph = tree.distance_graph.copy()
110
+
111
+ genome_dir = tmp_path / "genome_index" / "0" / "0"
112
+ genome_dir.mkdir(parents=True)
113
+ dna = "ATG" + "AAA" * 99
114
+ genomic_dna = dna if strand == 1 else str(Seq(dna).reverse_complement())
115
+ (genome_dir / "ref.fa").write_text(
116
+ ">0\n" + "A" * 200 + genomic_dna + "A" * 700 + "\n")
117
+ (genome_dir / "out.paf").write_text(
118
+ _paf_line(expected_node, protein_length, matches, 300,
119
+ strand="+" if strand == 1 else "-"))
120
+ monkeypatch.setattr(gene_retriever, "sfw",
121
+ SimpleNamespace(seqtk="seqtk", miniprot="miniprot"))
122
+ monkeypatch.setattr(gene_retriever, "run_command", Mock())
123
+
124
+ # Keep the PAF parser, graph updates, extraction, and annotation writing real.
125
+ graph, pg, tree, realign_roots = gene_retriever.retrieve_gene_from_genome(
126
+ graph, pg, tree)
127
+
128
+ annotation = (tmp_path / "total.involved_retrieved_annot.tsv").read_text()
129
+ if outcome == "rejected":
130
+ assert annotation == ""
131
+ assert retrieved_gene not in pg.annot
132
+ assert not graph.has_node(retrieved_gene)
133
+ assert graph.nodes[expected_node]["members"] == {expected_node}
134
+ assert not tree.distance_graph.has_node(retrieved_gene)
135
+ assert realign_roots == set()
136
+ else:
137
+ fields = annotation.strip().split("\t")
138
+ assert fields[0] == "#" + retrieved_gene
139
+ assert fields[7].split("|")[:2] == ["coding", str(matches / 300)]
140
+ assert fields[8] == dna
141
+ assert tree.distance_graph[retrieved_gene][expected_node]["weight"] == matches / 300
142
+ assert realign_roots == {0}
143
+ if outcome == "inserted":
144
+ assert set(graph.neighbors(retrieved_gene)) == {left_node, right_node}
145
+ assert graph.nodes[expected_node]["members"] == {expected_node}
146
+ else:
147
+ assert not graph.has_node(retrieved_gene)
148
+ assert retrieved_gene in graph.nodes[expected_node]["members"]
149
+ assert set(graph.neighbors(expected_node)) == {left_node, right_node}
150
+
151
+
152
+ @pytest.mark.parametrize("strand", [1, -1])
153
+ @pytest.mark.parametrize(
154
+ "dna,gcode,protein,status",
155
+ [
156
+ pytest.param("ATGAAATAAGGG", 11, "MK*G", "pseudo", id="internal-taa"),
157
+ pytest.param("ATGAAATAGGGG", 11, "MK*G", "pseudo", id="internal-tag"),
158
+ pytest.param("ATGAAATGAGGG", 11, "MK*G", "pseudo", id="internal-tga"),
159
+ pytest.param("ATGAAAGGGTAA", 11, "MKG*", "coding", id="terminal-stop"),
160
+ pytest.param("ATGAAAGGG", 11, "MKG", "coding", id="no-terminal-stop"),
161
+ pytest.param("AAAGGG", 11, "KG", "coding", id="no-start-codon"),
162
+ pytest.param("ATGAAATAAGGGTAA", 11, "MK*G*", "pseudo", id="both-stops"),
163
+ pytest.param("ATGAAATAATAA", 11, "MK**", "pseudo", id="consecutive-stops"),
164
+ pytest.param("ATGAAAGGGA", 11, "MKG", "pseudo", id="partial-codon"),
165
+ pytest.param("ATGAAATGAGGG", 4, "MKWG", "coding", id="alternative-code"),
166
+ ],
167
+ )
168
+ def test_retrieved_gene_classification(tmp_path, monkeypatch, strand, dna, gcode,
169
+ protein, status):
170
+ expected_node = "1:0:0"
171
+ left_node, right_node = "0:0:0", "0:0:1"
172
+ retrieved_gene = "0:0:2"
173
+ graph = nx.Graph()
174
+ graph.add_node(expected_node, length=10, members={expected_node},
175
+ strains={1}, repre_nodes=[expected_node])
176
+ for node, other_member in [(left_node, "1:0:1"), (right_node, "1:0:2")]:
177
+ graph.add_node(node, length=5, members={node, other_member},
178
+ strains={0, 1}, repre_nodes=[node])
179
+ graph.add_edge(expected_node, node)
180
+
181
+ pg = Pangenome(outdir=str(tmp_path), threads=1, gcode=gcode, disable=True)
182
+ pg.para_id, pg.orth_id = 0.7, 0.98
183
+ pg.nucl_fa = {}
184
+ pg.prot_file = str(tmp_path / "query-source.fa")
185
+ pg.annot = {
186
+ expected_node: {"len": 10, "id": "reference_gene"},
187
+ left_node: {"len": 5},
188
+ right_node: {"len": 5},
189
+ }
190
+ pg.annot_contig_map = {"0:0": "contig_1"}
191
+ pg.gene_rank = {"0:0": [[0, 300], []] if strand == 1 else [[], [0, 300]]}
192
+ pg.strain_dict = {0: SimpleNamespace(strain_name="target_strain")}
193
+
194
+ tree = Tree()
195
+ tree.leaf_root[expected_node] = 0
196
+ tree.root_leaf[0].add(expected_node)
197
+ tree.distance_graph.add_node(expected_node)
198
+ tree.raw_distance_graph = tree.distance_graph.copy()
199
+
200
+ genome_dir = tmp_path / "genome_index" / "0" / "0"
201
+ genome_dir.mkdir(parents=True)
202
+ genomic_dna = dna if strand == 1 else str(Seq(dna).reverse_complement())
203
+ (genome_dir / "ref.fa").write_text(
204
+ ">0\n" + "A" * 100 + genomic_dna + "A" * 300 + "\n",
205
+ encoding="utf-8",
206
+ )
207
+
208
+ # Supply a controlled hit while exercising the real extraction and output path.
209
+ monkeypatch.setattr(gene_retriever, "sfw",
210
+ SimpleNamespace(seqtk="seqtk", miniprot="miniprot"))
211
+ monkeypatch.setattr(gene_retriever, "run_command", Mock())
212
+ monkeypatch.setattr(gene_retriever, "retrieve_from_paf", Mock(return_value={
213
+ "0": {expected_node: {
214
+ "identity": 0.99,
215
+ "cigar": "cg:Z:4M",
216
+ "loc": {"start": 100, "end": 100 + len(dna), "strand": strand},
217
+ }},
218
+ }))
219
+
220
+ graph, pg, tree, realign_roots = gene_retriever.retrieve_gene_from_genome(
221
+ graph, pg, tree)
222
+
223
+ rows = (tmp_path / "total.involved_retrieved_annot.tsv").read_text().splitlines()
224
+ assert len(rows) == 1
225
+ fields = rows[0].split("\t")
226
+ assert fields[0] == "#" + retrieved_gene
227
+ assert fields[7].split("|")[0] == status
228
+ assert fields[8] == dna
229
+ assert fields[9] == protein
230
+ assert int(fields[4]) == len(protein)
231
+ assert pg.annot[retrieved_gene]["product"] == fields[7]
232
+ assert retrieved_gene in graph.nodes[expected_node]["members"]
233
+ if status == "pseudo":
234
+ assert retrieved_gene in tree.pseudo_root_leaf[0]
235
+ assert retrieved_gene not in pg.nucl_fa
236
+ assert retrieved_gene not in tree.root_leaf[0]
237
+ assert realign_roots == set()
238
+ else:
239
+ assert retrieved_gene not in tree.pseudo_root_leaf[0]
240
+ assert str(pg.nucl_fa[retrieved_gene]) == dna
241
+ assert retrieved_gene in tree.root_leaf[0]
242
+ assert realign_roots == {0}
243
+
244
+
245
+ @pytest.mark.parametrize("gcode,protein", [(4, "MKW"), (11, "MK*")])
246
+ def test_realign_uses_configured_genetic_code(tmp_path, monkeypatch, gcode, protein):
247
+ pg = Pangenome(outdir=str(tmp_path), threads=1, gcode=gcode, disable=True)
248
+ pg.nucl_fa = {"0:0:0": Seq("ATGAAATGA")}
249
+ tree = Tree()
250
+ tree.root_leaf[0].update(["0:0:0", "1:0:0", "2:0:0"])
251
+ parser = Mock(return_value=(None, None))
252
+ monkeypatch.setattr(gene_retriever, "realign_parser", parser)
253
+ monkeypatch.setattr(gene_retriever, "sfw",
254
+ SimpleNamespace(mcxdeblast="mcxdeblast", mcl="mcl"))
255
+
256
+ gene_retriever.realign(pg, tree, str(tmp_path), {0})
257
+
258
+ record = SeqIO.read(tmp_path / "seq.fa", "fasta")
259
+ assert record.id == "0:0:0"
260
+ assert str(record.seq) == protein
261
+ parser.assert_called_once()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes