Skip to content

Commit 48b8114

Browse files
committed
Add Species.division and prettier 'pyensembl available' output
Species now carries a division field ("vertebrates" by default, plus "plants", "fungi", "metazoa", "protists", "bacteria") and exposes matching predicates (is_plant, is_fungus, is_animal, is_vertebrate, is_invertebrate, is_protist, is_bacterium). The legacy is_plant keyword on Species.register is preserved and translates to division="plants" so external callers keep working. Existing species are tagged taxonomically: arabidopsis/rice → plants, yeast → fungi, drosophila/C. elegans → metazoa. URL routing still goes through is_plant, so no behavior change for installs in this release. pyensembl available now renders as a single aligned table with division section headers (Vertebrates / Invertebrates / Plants / Fungi), an en-dashed release range, single-release entries shown as the bare number, and a dimmed latin-name column. ANSI styling is auto-enabled when stdout is a TTY and suppressed when piped. Unblocks #298 (adding more plants / fungi / metazoa species) by giving registrations a real taxonomic field to plug into. Bump version to 2.8.0.
1 parent ca51c48 commit 48b8114

4 files changed

Lines changed: 212 additions & 20 deletions

File tree

pyensembl/shell.py

Lines changed: 105 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -252,22 +252,115 @@ def collect_selected_genomes(args):
252252
return all_combinations_of_ensembl_genomes(args)
253253

254254

255-
def format_available_species():
255+
_DIVISION_LABELS = (
256+
("vertebrates", "Vertebrates"),
257+
("metazoa", "Invertebrates"),
258+
("plants", "Plants"),
259+
("fungi", "Fungi"),
260+
("protists", "Protists"),
261+
("bacteria", "Bacteria"),
262+
)
263+
264+
265+
def _format_release_range(start, end):
266+
# Inclusive range; collapse a single-value range to just that integer.
267+
if start == end:
268+
return str(start)
269+
return "%d–%d" % (start, end) # en-dash
270+
271+
272+
def _species_display_name(species):
273+
if species.synonyms:
274+
return species.synonyms[0]
275+
return species.latin_name
276+
277+
278+
def format_available_species(use_color=None):
256279
"""
257-
Build the multi-line string printed by the "available" action: every
258-
registered species followed by its supported Ensembl release ranges,
259-
grouped by reference assembly.
280+
Render the table printed by the "available" CLI action: every registered
281+
species and its supported Ensembl release ranges, grouped by division.
282+
283+
When ``use_color`` is ``None`` (the default), ANSI styling is applied if
284+
stdout is a TTY and suppressed otherwise.
260285
"""
261-
lines = []
286+
import sys
287+
288+
if use_color is None:
289+
use_color = sys.stdout.isatty()
290+
BOLD = "\x1b[1m" if use_color else ""
291+
DIM = "\x1b[2m" if use_color else ""
292+
RESET = "\x1b[0m" if use_color else ""
293+
294+
species_by_division = {key: [] for key, _ in _DIVISION_LABELS}
262295
for latin_name in sorted(Species._latin_names_to_species):
263296
species = Species._latin_names_to_species[latin_name]
264-
if species.synonyms:
265-
synonyms = ",".join(species.synonyms)
266-
lines.append("* %s (%s):" % (latin_name, synonyms))
267-
else:
268-
lines.append("* %s:" % latin_name)
269-
for assembly, (start, end) in species.reference_assemblies.items():
270-
lines.append(" * %s: (%d, %d)" % (assembly, start, end))
297+
species_by_division.setdefault(species.division, []).append(species)
298+
299+
all_species = [s for group in species_by_division.values() for s in group]
300+
if not all_species:
301+
return ""
302+
303+
def _w(values, fallback):
304+
return max((len(v) for v in values), default=fallback)
305+
306+
name_w = _w([_species_display_name(s) for s in all_species], 8)
307+
asm_w = _w(
308+
[asm for s in all_species for asm in s.reference_assemblies], 8
309+
)
310+
rng_w = _w(
311+
[
312+
_format_release_range(start, end)
313+
for s in all_species
314+
for (start, end) in s.reference_assemblies.values()
315+
],
316+
8,
317+
)
318+
latin_w = _w([s.latin_name for s in all_species], 8)
319+
320+
col_name = max(name_w, len("Species")) + 2
321+
col_asm = max(asm_w, len("Assembly")) + 2
322+
col_rng = max(rng_w, len("Releases")) + 2
323+
col_latin = max(latin_w, len("Latin name"))
324+
total_w = col_name + col_asm + col_rng + col_latin
325+
326+
lines = []
327+
header_row = "%-*s%-*s%-*s%s" % (
328+
col_name, "Species",
329+
col_asm, "Assembly",
330+
col_rng, "Releases",
331+
"Latin name",
332+
)
333+
lines.append("%s%s%s" % (BOLD, header_row, RESET))
334+
lines.append("─" * total_w)
335+
336+
for division_key, division_label in _DIVISION_LABELS:
337+
members = species_by_division.get(division_key, [])
338+
if not members:
339+
continue
340+
members.sort(key=_species_display_name)
341+
lines.append("")
342+
section = "── %s " % division_label
343+
section += "─" * max(2, total_w - len(section))
344+
lines.append("%s%s%s" % (BOLD, section, RESET))
345+
for species in members:
346+
for i, (asm, (start, end)) in enumerate(
347+
species.reference_assemblies.items()
348+
):
349+
if i == 0:
350+
name_cell = _species_display_name(species)
351+
latin_cell = "%s%s%s" % (DIM, species.latin_name, RESET)
352+
else:
353+
name_cell = ""
354+
latin_cell = ""
355+
lines.append(
356+
"%-*s%-*s%-*s%s"
357+
% (
358+
col_name, name_cell,
359+
col_asm, asm,
360+
col_rng, _format_release_range(start, end),
361+
latin_cell,
362+
)
363+
)
271364
return "\n".join(lines)
272365

273366

pyensembl/species.py

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,28 @@ class Species(Serializable):
3030
_reference_names_to_species = {}
3131

3232
@classmethod
33-
def register(cls, latin_name, synonyms, reference_assemblies, is_plant=False):
33+
def register(
34+
cls,
35+
latin_name,
36+
synonyms,
37+
reference_assemblies,
38+
division="vertebrates",
39+
is_plant=False,
40+
):
3441
"""
3542
Create a Species object from the given arguments and enter into
3643
all the dicts used to look the species up by its fields.
44+
45+
``is_plant`` is retained for backward compatibility; new callers
46+
should pass ``division`` instead.
3747
"""
48+
if is_plant:
49+
division = "plants"
3850
species = Species(
3951
latin_name=latin_name,
4052
synonyms=synonyms,
4153
reference_assemblies=reference_assemblies,
42-
is_plant=is_plant,
54+
division=division,
4355
)
4456
cls._latin_names_to_species[species.latin_name] = species
4557
for synonym in synonyms:
@@ -81,7 +93,17 @@ def all_species_release_pairs(cls):
8193
for release in range(release_range[0], release_range[1] + 1):
8294
yield species_name, release
8395

84-
def __init__(self, latin_name, synonyms=[], reference_assemblies={}, is_plant=False):
96+
VALID_DIVISIONS = frozenset(
97+
{"vertebrates", "plants", "fungi", "metazoa", "protists", "bacteria"}
98+
)
99+
100+
def __init__(
101+
self,
102+
latin_name,
103+
synonyms=[],
104+
reference_assemblies={},
105+
division="vertebrates",
106+
):
85107
"""
86108
Parameters
87109
----------
@@ -92,12 +114,22 @@ def __init__(self, latin_name, synonyms=[], reference_assemblies={}, is_plant=Fa
92114
reference_assemblies : dict
93115
Mapping of names of reference genomes onto inclusive ranges of
94116
Ensembl releases Example: {"GRCh37": (54, 75)}
117+
118+
division : str
119+
Ensembl division this species belongs to. One of
120+
``"vertebrates"``, ``"plants"``, ``"fungi"``, ``"metazoa"``,
121+
``"protists"``, ``"bacteria"``. Defaults to ``"vertebrates"``.
95122
"""
123+
if division not in self.VALID_DIVISIONS:
124+
raise ValueError(
125+
"Invalid division %r for %s; must be one of %s"
126+
% (division, latin_name, sorted(self.VALID_DIVISIONS))
127+
)
96128
self.latin_name = latin_name.lower().replace(" ", "_")
97129
self.synonyms = synonyms
98130
self.reference_assemblies = reference_assemblies
131+
self.division = division
99132
self._release_to_genome = {}
100-
self.is_plant = is_plant
101133
for genome_name, (start, end) in self.reference_assemblies.items():
102134
for i in range(start, end + 1):
103135
if i in self._release_to_genome:
@@ -107,6 +139,34 @@ def __init__(self, latin_name, synonyms=[], reference_assemblies={}, is_plant=Fa
107139
)
108140
self._release_to_genome[i] = genome_name
109141

142+
@property
143+
def is_plant(self):
144+
return self.division == "plants"
145+
146+
@property
147+
def is_fungus(self):
148+
return self.division == "fungi"
149+
150+
@property
151+
def is_vertebrate(self):
152+
return self.division == "vertebrates"
153+
154+
@property
155+
def is_invertebrate(self):
156+
return self.division == "metazoa"
157+
158+
@property
159+
def is_animal(self):
160+
return self.division in ("vertebrates", "metazoa")
161+
162+
@property
163+
def is_protist(self):
164+
return self.division == "protists"
165+
166+
@property
167+
def is_bacterium(self):
168+
return self.division == "bacteria"
169+
110170
def which_reference(self, ensembl_release):
111171
if ensembl_release not in self._release_to_genome:
112172
raise ValueError(
@@ -330,6 +390,7 @@ def check_species_object(species_name_or_object):
330390
"BDGP6.28": (99, 102),
331391
"BDGP6.32": (103, MAX_ENSEMBL_RELEASE),
332392
},
393+
division="metazoa",
333394
)
334395

335396
nematode = Species.register(
@@ -344,6 +405,7 @@ def check_species_object(species_name_or_object):
344405
"WBcel215": (67, 70),
345406
"WBcel235": (71, MAX_ENSEMBL_RELEASE),
346407
},
408+
division="metazoa",
347409
)
348410

349411
yeast = Species.register(
@@ -352,6 +414,7 @@ def check_species_object(species_name_or_object):
352414
reference_assemblies={
353415
"R64-1-1": (76, MAX_ENSEMBL_RELEASE),
354416
},
417+
division="fungi",
355418
)
356419

357420
arabidopsis_thaliana = Species.register(
@@ -360,7 +423,7 @@ def check_species_object(species_name_or_object):
360423
reference_assemblies={
361424
"TAIR10": (40, MAX_PLANTS_ENSEMBL_RELEASE),
362425
},
363-
is_plant=True
426+
division="plants",
364427
)
365428

366429
rice = Species.register(
@@ -369,7 +432,7 @@ def check_species_object(species_name_or_object):
369432
reference_assemblies={
370433
"IRGSP-1.0": (40, MAX_PLANTS_ENSEMBL_RELEASE),
371434
},
372-
is_plant=True
435+
division="plants",
373436
)
374437

375438
#BALB/c

pyensembl/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
__version__ = "2.7.0"
1+
__version__ = "2.8.0"
22

33
def print_version():
44
print(f"v{__version__}")

tests/test_shell.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ def test_available_action_parses():
2121

2222

2323
def test_format_available_species_includes_human_and_assemblies():
24-
output = format_available_species()
24+
output = format_available_species(use_color=False)
2525
# human is registered with common name "human" and three reference assemblies
2626
assert "homo_sapiens" in output
2727
assert "human" in output
@@ -30,3 +30,39 @@ def test_format_available_species_includes_human_and_assemblies():
3030
# mouse should also appear
3131
assert "mus_musculus" in output
3232
assert "GRCm38" in output
33+
34+
35+
def test_format_available_species_grouped_by_division():
36+
output = format_available_species(use_color=False)
37+
# Every populated division should have a section header.
38+
assert "── Vertebrates " in output
39+
assert "── Invertebrates " in output
40+
assert "── Plants " in output
41+
assert "── Fungi " in output
42+
# Section ordering: Vertebrates before Invertebrates before Plants before Fungi.
43+
v = output.index("── Vertebrates ")
44+
i = output.index("── Invertebrates ")
45+
p = output.index("── Plants ")
46+
f = output.index("── Fungi ")
47+
assert v < i < p < f
48+
# Yeast is now classified as fungi; drosophila/c. elegans as metazoa.
49+
assert output.index("yeast") > f
50+
drosophila_pos = output.index("drosophila")
51+
assert i < drosophila_pos < p
52+
53+
54+
def test_format_available_species_no_color_has_no_escape_codes():
55+
output = format_available_species(use_color=False)
56+
assert "\x1b[" not in output
57+
58+
59+
def test_format_available_species_collapses_single_release():
60+
# NCBI36 only exists in Ensembl release 54; verify it renders as "54"
61+
# rather than "54–54".
62+
output = format_available_species(use_color=False)
63+
assert "NCBI36" in output
64+
ncbi36_line = next(
65+
line for line in output.splitlines() if "NCBI36" in line
66+
)
67+
assert "54–54" not in ncbi36_line
68+
assert "54" in ncbi36_line

0 commit comments

Comments
 (0)