Skip to content

Commit 6bd0c58

Browse files
AdamtarantoCopilottanghaibao
authored
Add Python versions 3.13 and 3.14 to CI workflow (#828)
* Add Python versions 3.13 and 3.14 to CI workflow Upper limit removed in #826 * Migrate ete3 → ete4 for Python 3.13+ compatibility (#829) * Initial plan * Migrate from ete3 to ete4 for Python 3.13+ compatibility Co-authored-by: tanghaibao <106987+tanghaibao@users.noreply.github.com> * Style fixes by Black --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: tanghaibao <106987+tanghaibao@users.noreply.github.com> * Fix bam2mat tests failing on Python 3.13 due to pysam iterator ValueError (#830) * Initial plan * Fix bam2mat ValueError with Python 3.13 + pysam + pytest-cov Co-authored-by: tanghaibao <106987+tanghaibao@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: tanghaibao <106987+tanghaibao@users.noreply.github.com> --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: tanghaibao <106987+tanghaibao@users.noreply.github.com>
1 parent af34603 commit 6bd0c58

8 files changed

Lines changed: 144 additions & 63 deletions

File tree

.github/workflows/pytest.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ jobs:
1111
max-parallel: 4
1212
matrix:
1313
os: [ubuntu-latest, macos-latest]
14-
python-version: ["3.10", "3.11", "3.12"]
14+
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
1515
steps:
1616
- uses: actions/checkout@v6
1717
- name: Set up Python ${{ matrix.python-version }}
@@ -48,7 +48,7 @@ jobs:
4848
# Only upload coverage for the latest Python version on Ubuntu
4949
#- name: Upload coverage reports to Codecov
5050
# uses: codecov/codecov-action@v5
51-
# if: matrix.python-version == '3.12' && matrix.os == 'ubuntu-latest'
51+
# if: matrix.python-version == '3.14' && matrix.os == 'ubuntu-latest'
5252
# with:
5353
# token: ${{ secrets.CODECOV_TOKEN }}
5454
# slug: tanghaibao/jcvi

pyproject.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ dependencies = [
3737
"CrossMap",
3838
"cython",
3939
"deap",
40-
"ete3",
40+
"ete4",
4141
"ftpretty",
4242
"genomepy",
4343
"gffutils",
@@ -125,7 +125,6 @@ addopts = "-v --cov=jcvi --cov-branch --cov-report=xml --cov-report=term"
125125
testpaths = ["tests"]
126126
python_files = ["test_*.py"]
127127
filterwarnings = [
128-
"ignore:invalid escape sequence.*:SyntaxWarning:ete3\\..*",
129128
]
130129

131130
[tool.isort]

src/jcvi/apps/phylo.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
FSeqBootCommandline,
4242
)
4343
from Bio.Phylo.Applications import PhymlCommandline, RaxmlCommandline
44-
from ete3 import Tree
44+
from ete4 import Tree
4545
import numpy as np
4646

4747
from ..compara.ks import (
@@ -252,8 +252,8 @@ def smart_reroot(treefile, outgroupfile, outfile, format=0):
252252
Tree reading format options see here:
253253
http://packages.python.org/ete2/tutorial/tutorial_trees.html#reading-newick-trees
254254
"""
255-
tree = Tree(treefile, format=format)
256-
leaves = [t.name for t in tree.get_leaves()][::-1]
255+
tree = Tree(treefile, parser=format)
256+
leaves = [t.name for t in list(tree.leaves())][::-1]
257257
outgroup = []
258258
for o in must_open(outgroupfile):
259259
o = o.strip()
@@ -271,12 +271,12 @@ def smart_reroot(treefile, outgroupfile, outfile, format=0):
271271
return treefile
272272

273273
try:
274-
tree.set_outgroup(tree.get_common_ancestor(*outgroup))
274+
tree.set_outgroup(tree.common_ancestor(*outgroup))
275275
except ValueError:
276276
assert type(outgroup) == list
277277
outgroup = outgroup[0]
278278
tree.set_outgroup(outgroup)
279-
tree.write(outfile=outfile, format=format)
279+
tree.write(outfile=outfile, parser=format)
280280

281281
logger.debug("Rerooted tree printed to {0}".format(outfile))
282282
return outfile
@@ -374,9 +374,9 @@ def build_nj_phylip(alignment, outfile, outgroup, work_dir="."):
374374
nodesupport[node_children] = node.dist / 100.0
375375

376376
for k, v in nodesupport.items():
377-
ct_b.get_common_ancestor(*k).support = v
377+
ct_b.common_ancestor(*k).support = v
378378
print(ct_b)
379-
ct_b.write(format=0, outfile=outfile)
379+
ct_b.write(outfile=outfile, parser=0)
380380

381381
try:
382382
s = op.getsize(outfile)

src/jcvi/assembly/hic.py

Lines changed: 41 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1007,42 +1007,50 @@ def distbin_number(dist, start=minsize, ratio=1.01):
10071007
# Check all reads, rules borrowed from LACHESIS
10081008
# https://github.com/shendurelab/LACHESIS/blob/master/src/GenomeLinkMatrix.cc#L1476
10091009
j = k = 0
1010-
for c in bamfile:
1011-
j += 1
1012-
if j % 100000 == 0:
1013-
print("{} reads counted".format(j), file=sys.stderr)
1010+
try:
1011+
for c in bamfile:
1012+
j += 1
1013+
if j % 100000 == 0:
1014+
print("{} reads counted".format(j), file=sys.stderr)
10141015

1015-
if c.is_qcfail and c.is_duplicate:
1016-
continue
1017-
if c.is_secondary and c.is_supplementary:
1018-
continue
1019-
if c.mapping_quality == 0:
1020-
continue
1021-
if not c.is_paired:
1022-
continue
1023-
if c.is_read2: # Take only one read
1024-
continue
1025-
1026-
# pysam v0.8.3 does not support keyword reference_name
1027-
achr = bamfile.getrname(c.reference_id)
1028-
apos = c.reference_start
1029-
bchr = bamfile.getrname(c.next_reference_id)
1030-
bpos = c.next_reference_start
1031-
if achr not in seqstarts or bchr not in seqstarts:
1032-
continue
1033-
if achr == bchr:
1034-
dist = abs(apos - bpos)
1035-
if dist < minsize:
1016+
if c.is_qcfail and c.is_duplicate:
1017+
continue
1018+
if c.is_secondary and c.is_supplementary:
1019+
continue
1020+
if c.mapping_quality == 0:
1021+
continue
1022+
if not c.is_paired:
1023+
continue
1024+
if c.is_read2: # Take only one read
10361025
continue
1037-
db = distbin_number(dist)
1038-
B[db] += 1
1039-
1040-
abin, bbin = bin_number(achr, apos), bin_number(bchr, bpos)
1041-
A[abin, bbin] += 1
1042-
if abin != bbin:
1043-
A[bbin, abin] += 1
10441026

1045-
k += 1
1027+
# pysam v0.8.3 does not support keyword reference_name
1028+
achr = bamfile.getrname(c.reference_id)
1029+
apos = c.reference_start
1030+
bchr = bamfile.getrname(c.next_reference_id)
1031+
bpos = c.next_reference_start
1032+
if achr not in seqstarts or bchr not in seqstarts:
1033+
continue
1034+
if achr == bchr:
1035+
dist = abs(apos - bpos)
1036+
if dist < minsize:
1037+
continue
1038+
db = distbin_number(dist)
1039+
B[db] += 1
1040+
1041+
abin, bbin = bin_number(achr, apos), bin_number(bchr, bpos)
1042+
A[abin, bbin] += 1
1043+
if abin != bbin:
1044+
A[bbin, abin] += 1
1045+
1046+
k += 1
1047+
except ValueError as e:
1048+
# pysam >= 0.23.1 raises ValueError("Firing event 10 with no exception set")
1049+
# at iterator exhaustion on Python 3.13 when pytest-cov is active.
1050+
# All records have been processed at this point, so we only re-raise
1051+
# unexpected errors. See: https://github.com/pysam-developers/pysam/issues/1350
1052+
if "Firing event 10 with no exception set" not in str(e):
1053+
raise
10461054

10471055
logger.debug("Total reads counted: %s", percentage(2 * k, j))
10481056
bamfile.close()

src/jcvi/formats/cdt.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def get_names(self):
4545

4646
def get_gtr_tree(self):
4747

48-
from ete3 import Tree
48+
from ete4 import Tree
4949

5050
fp = open(self.gtrfile)
5151
reader = csv.reader(fp, delimiter="\t")
@@ -68,7 +68,7 @@ def get_gtr_tree(self):
6868

6969
def print_newick(self, nwk_file):
7070

71-
self.gtr_tree.write(format=5, outfile=nwk_file)
71+
self.gtr_tree.write(outfile=nwk_file, parser=5)
7272
logger.debug("Newick tree written to `%s`", nwk_file)
7373

7474
def iter_partitions(self, cutoff=0.3, gtr=True):

src/jcvi/graphics/tree.py

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from itertools import groupby
66
import sys
77

8-
from ete3 import Tree
8+
from ete4 import Tree
99

1010
from ..apps.base import OptionParser, glob, logger
1111
from ..formats.base import LineFile
@@ -177,12 +177,20 @@ def draw_tree(
177177

178178
if reroot:
179179
if outgroup:
180-
R = t.get_common_ancestor(*outgroup)
180+
R = t.common_ancestor(*outgroup)
181181
else:
182182
# Calculate the midpoint node
183183
R = t.get_midpoint_outgroup()
184184

185185
if R is not t:
186+
# ete4 requires consistent support at root before rerooting
187+
root_children_support = [c.support for c in t.children]
188+
if None in root_children_support and any(
189+
s is not None for s in root_children_support
190+
):
191+
for child in t.children:
192+
if child.support is None:
193+
child.support = 1.0
186194
t.set_outgroup(R)
187195

188196
# By default, the distance to outgroup and non-outgroup is the same
@@ -192,7 +200,7 @@ def draw_tree(
192200
a, b = t.children
193201
# Avoid even split
194202
total = a.dist + b.dist
195-
newR = t.get_common_ancestor(*outgroup)
203+
newR = t.common_ancestor(*outgroup)
196204
a.dist = 0.9 * total
197205
b.dist = total - a.dist
198206

@@ -210,7 +218,7 @@ def rescale(dist):
210218
def rescale_divergence(divergence):
211219
return rescale(max_dist - divergence)
212220

213-
num_leaves = len(t.get_leaf_names())
221+
num_leaves = len(list(t.leaf_names()))
214222
yinterval = (1 - ystart) / num_leaves
215223
ytop = ystart + (num_leaves - 0.5) * yinterval
216224

@@ -228,10 +236,10 @@ def rescale_divergence(divergence):
228236
i = 0
229237
color_groups = [] # Used to plot groups to the right of the tree
230238
for n in t.traverse("postorder"):
231-
dist = n.get_distance(t)
239+
dist = t.get_distance(n, t)
232240
xx = rescale(dist)
233241

234-
if n.is_leaf():
242+
if n.is_leaf:
235243
yy = ystart + i * yinterval
236244
i += 1
237245

@@ -304,10 +312,10 @@ def rescale_divergence(divergence):
304312
alpha=0.4,
305313
lw=2,
306314
)
307-
support = n.support
315+
support = n.support if n.support is not None else 0
308316
if support > 1:
309317
support = support / 100.0
310-
if not n.is_root() and supportcolor:
318+
if not n.is_root and supportcolor:
311319
if support > scutoff / 100.0:
312320
ax.text(
313321
xx,
@@ -535,7 +543,7 @@ def repl(match):
535543
if repl.hpd:
536544
print(repl.hpd, file=sys.stderr)
537545

538-
return (Tree(treedata, format=1), repl.hpd) if changed else (Tree(treedata), None)
546+
return (Tree(treedata, parser=1), repl.hpd) if changed else (Tree(treedata), None)
539547

540548

541549
def main(args):

src/jcvi/utils/taxonomy.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535

3636
from BeautifulSoup import BeautifulSoup
3737
from ClientForm import ParseResponse
38-
from ete3 import Tree
38+
from ete4 import Tree
3939

4040
from ..apps.base import ActionDispatcher, OptionParser, logger
4141

@@ -85,7 +85,7 @@ def __str__(self):
8585
return self.newick
8686

8787
def print_tree(self):
88-
t = Tree(self.newick, format=8)
88+
t = Tree(self.newick, parser=8)
8989
print(t)
9090

9191

@@ -122,9 +122,9 @@ def MRCA(list_of_taxids):
122122
"""
123123

124124
t = TaxIDTree(list_of_taxids)
125-
t = Tree(str(t), format=8)
125+
t = Tree(str(t), parser=8)
126126

127-
ancestor = t.get_common_ancestor(*t.get_leaves())
127+
ancestor = t.common_ancestor(*list(t.leaves()))
128128

129129
return ancestor.name
130130

tests/graphics/test_tree.py

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,81 @@
33

44
import os
55
import os.path as op
6+
import tempfile
67

8+
import matplotlib
79

8-
def disabled_test_tree_main():
10+
matplotlib.use("Agg")
11+
12+
import pytest
13+
14+
15+
def test_tree_main(tmp_path):
16+
"""Render the built-in demo tree to a PNG and check the file is created."""
917
from jcvi.apps.base import cleanup
1018
from jcvi.graphics.tree import main as tree_main
1119

12-
demo = "demo.png"
20+
demo = str(tmp_path / "demo.png")
1321
cleanup(demo)
1422

23+
os.chdir(tmp_path)
1524
tree_main(["demo", "--format", "png"])
16-
assert op.exists(demo)
17-
cleanup(demo)
25+
assert op.exists(str(tmp_path / "demo.png"))
26+
27+
28+
def test_tree_parse():
29+
"""Test that the ete4 Tree can be created from a newick string."""
30+
from ete4 import Tree
31+
32+
newick = "(((A:0.1,B:0.2)90:0.3,(C:0.15,D:0.25)80:0.2)100:0.05,E:0.4);"
33+
t = Tree(newick)
34+
assert t is not None
35+
leaves = list(t.leaf_names())
36+
assert sorted(leaves) == ["A", "B", "C", "D", "E"]
37+
38+
39+
def test_tree_parse_tree_function():
40+
"""Test parse_tree handles a plain newick file (no HPD)."""
41+
from jcvi.graphics.tree import parse_tree
42+
43+
newick = "(((A:0.1,B:0.2)90:0.3,(C:0.15,D:0.25)80:0.2)100:0.05,E:0.4);"
44+
with tempfile.NamedTemporaryFile(mode="w", suffix=".nwk", delete=False) as f:
45+
f.write(newick)
46+
fname = f.name
47+
try:
48+
t, hpd = parse_tree(fname)
49+
assert hpd is None
50+
leaves = list(t.leaf_names())
51+
assert sorted(leaves) == ["A", "B", "C", "D", "E"]
52+
finally:
53+
os.unlink(fname)
54+
55+
56+
def test_tree_truncate_name():
57+
"""Test the truncate_name helper function."""
58+
from jcvi.graphics.tree import truncate_name
59+
60+
assert truncate_name("ABCDEF", rule=None) == "ABCDEF"
61+
assert truncate_name("ABCDEF", rule="head3") == "DEF"
62+
assert truncate_name("ABCDEF", rule="ohead3") == "ABC"
63+
assert truncate_name("ABCDEF", rule="tail2") == "ABCD"
64+
assert truncate_name("ABCDEF", rule="otail2") == "EF"
65+
66+
67+
def test_tree_draw(tmp_path):
68+
"""Test that draw_tree runs without error on a simple tree."""
69+
import matplotlib.pyplot as plt
70+
from ete4 import Tree
71+
72+
from jcvi.graphics.tree import draw_tree
73+
74+
newick = "(((A:0.1,B:0.2)0.9:0.3,(C:0.15,D:0.25)0.8:0.2)1.0:0.05,E:0.4);"
75+
t = Tree(newick)
76+
77+
fig = plt.figure(figsize=(8, 6))
78+
ax = fig.add_axes([0, 0, 1, 1])
79+
draw_tree(ax, t)
80+
out = str(tmp_path / "tree_draw.png")
81+
fig.savefig(out)
82+
plt.close(fig)
83+
assert op.exists(out)

0 commit comments

Comments
 (0)