tree-annotated-plot 0.0.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.
- tree_annotated_plot/__init__.py +11 -0
- tree_annotated_plot/_config.py +215 -0
- tree_annotated_plot/_plot.py +1337 -0
- tree_annotated_plot/_tree.py +307 -0
- tree_annotated_plot/cli.py +165 -0
- tree_annotated_plot-0.0.1.dist-info/METADATA +225 -0
- tree_annotated_plot-0.0.1.dist-info/RECORD +10 -0
- tree_annotated_plot-0.0.1.dist-info/WHEEL +4 -0
- tree_annotated_plot-0.0.1.dist-info/entry_points.txt +2 -0
- tree_annotated_plot-0.0.1.dist-info/licenses/LICENSE +19 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""Parse Auspice JSON phylogenetic trees and compute a 2-D layout for drawing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import warnings
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Iterator
|
|
10
|
+
|
|
11
|
+
import pandas as pd
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class TreeNode:
|
|
16
|
+
"""A node in a phylogenetic tree.
|
|
17
|
+
|
|
18
|
+
`name` carries the resolved strain identifier for tips (the value at
|
|
19
|
+
`tree_strain_field`). For internal nodes, `name` is the Auspice top-level
|
|
20
|
+
`name` field — internal-node identity isn't used for chart-strain matching.
|
|
21
|
+
|
|
22
|
+
`x` is divergence from the root (read from `node_attrs.div`).
|
|
23
|
+
`y` is set by :func:`layout` and is the integer index of the node's tip
|
|
24
|
+
(for tips) or the midpoint of its descendants' tip indices (for internal
|
|
25
|
+
nodes).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
name: str
|
|
29
|
+
x: float
|
|
30
|
+
children: list["TreeNode"] = field(default_factory=list)
|
|
31
|
+
y: float | None = None
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def is_tip(self) -> bool:
|
|
35
|
+
return not self.children
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_auspice(
|
|
39
|
+
source: str | Path | dict,
|
|
40
|
+
*,
|
|
41
|
+
tree_strain_field: str,
|
|
42
|
+
branch_length: str = "div",
|
|
43
|
+
strict_version: bool = True,
|
|
44
|
+
) -> TreeNode:
|
|
45
|
+
"""Load an Auspice JSON tree from a path or dict.
|
|
46
|
+
|
|
47
|
+
Parameters
|
|
48
|
+
----------
|
|
49
|
+
source
|
|
50
|
+
Path to an Auspice JSON file, or an already-parsed dict.
|
|
51
|
+
tree_strain_field
|
|
52
|
+
Where on each tip to find the strain identifier. The literal string
|
|
53
|
+
``"name"`` selects the tip's top-level ``name`` field; any other value
|
|
54
|
+
``X`` selects ``node_attrs[X]`` (auto-unwrapping the Auspice
|
|
55
|
+
``{"value": ...}`` convention). Dotted paths are not accepted.
|
|
56
|
+
branch_length
|
|
57
|
+
Which Auspice node attribute supplies branch lengths. ``"div"``
|
|
58
|
+
(default) reads ``node_attrs.div`` (a scalar absolute divergence
|
|
59
|
+
from the root). ``"num_date"`` reads ``node_attrs.num_date.value``
|
|
60
|
+
(an absolute calendar position in years). In both cases the value
|
|
61
|
+
is stored on each ``TreeNode.x`` and is used as-is by layout,
|
|
62
|
+
segments, and pruning — they're branch-source-agnostic.
|
|
63
|
+
strict_version
|
|
64
|
+
When True (default), raise ``ValueError`` if the Auspice JSON's
|
|
65
|
+
top-level ``version`` field does not start with ``"v2"``. With
|
|
66
|
+
``False`` the same case becomes a ``warnings.warn``. A missing
|
|
67
|
+
``version`` field always warns and proceeds.
|
|
68
|
+
"""
|
|
69
|
+
_validate_tree_strain_field(tree_strain_field)
|
|
70
|
+
if branch_length not in ("div", "num_date"):
|
|
71
|
+
raise ValueError(
|
|
72
|
+
f"branch_length={branch_length!r} not supported; expected 'div' "
|
|
73
|
+
"or 'num_date'."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
if isinstance(source, (str, Path)):
|
|
77
|
+
with open(source) as f:
|
|
78
|
+
data = json.load(f)
|
|
79
|
+
elif isinstance(source, dict):
|
|
80
|
+
data = source
|
|
81
|
+
else:
|
|
82
|
+
raise ValueError(f"unsupported tree source type: {type(source).__name__}")
|
|
83
|
+
|
|
84
|
+
_check_auspice_version(data, strict_version=strict_version)
|
|
85
|
+
|
|
86
|
+
if "tree" not in data:
|
|
87
|
+
raise ValueError("Auspice JSON must have a top-level 'tree' field")
|
|
88
|
+
return _parse_node(data["tree"], tree_strain_field, branch_length)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _check_auspice_version(data: dict, *, strict_version: bool) -> None:
|
|
92
|
+
"""Inspect the Auspice top-level `version`; raise / warn on non-v2."""
|
|
93
|
+
version = data.get("version")
|
|
94
|
+
if version is None:
|
|
95
|
+
warnings.warn(
|
|
96
|
+
"Auspice JSON has no top-level 'version' field; proceeding, but "
|
|
97
|
+
"the structure is assumed to be Auspice v2.",
|
|
98
|
+
stacklevel=3,
|
|
99
|
+
)
|
|
100
|
+
return
|
|
101
|
+
if not isinstance(version, str) or not version.startswith("v2"):
|
|
102
|
+
msg = (
|
|
103
|
+
f"Auspice JSON has version={version!r}; this package only "
|
|
104
|
+
"supports v2. Regenerate the tree with a current Augur version."
|
|
105
|
+
)
|
|
106
|
+
if strict_version:
|
|
107
|
+
raise ValueError(msg)
|
|
108
|
+
warnings.warn(msg, stacklevel=3)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _validate_tree_strain_field(tree_strain_field: str) -> None:
|
|
112
|
+
if not isinstance(tree_strain_field, str) or not tree_strain_field:
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"tree_strain_field must be a non-empty string, got "
|
|
115
|
+
f"{tree_strain_field!r}"
|
|
116
|
+
)
|
|
117
|
+
if "." in tree_strain_field:
|
|
118
|
+
raise ValueError(
|
|
119
|
+
f"tree_strain_field={tree_strain_field!r} contains a dot. Dotted "
|
|
120
|
+
"paths are not supported. Use 'name' for the top-level Auspice "
|
|
121
|
+
"node `name` field, or a single attribute name (e.g. "
|
|
122
|
+
"'derived_haplotype') to look up under node_attrs (the "
|
|
123
|
+
"{'value': ...} Auspice convention is auto-unwrapped)."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _resolve_tip_strain(node_dict: dict, tree_strain_field: str) -> str:
|
|
128
|
+
"""Look up tree_strain_field on a tip and return its string value."""
|
|
129
|
+
if tree_strain_field == "name":
|
|
130
|
+
v = node_dict.get("name")
|
|
131
|
+
else:
|
|
132
|
+
attrs = node_dict.get("node_attrs", {})
|
|
133
|
+
if tree_strain_field not in attrs:
|
|
134
|
+
raise ValueError(
|
|
135
|
+
f"tree tip {node_dict.get('name', '?')!r} has no "
|
|
136
|
+
f"node_attrs[{tree_strain_field!r}]"
|
|
137
|
+
)
|
|
138
|
+
v = attrs[tree_strain_field]
|
|
139
|
+
if isinstance(v, dict) and "value" in v:
|
|
140
|
+
v = v["value"]
|
|
141
|
+
|
|
142
|
+
if not isinstance(v, str) or not v:
|
|
143
|
+
raise ValueError(
|
|
144
|
+
f"tree_strain_field={tree_strain_field!r} on tip "
|
|
145
|
+
f"{node_dict.get('name', '?')!r} resolved to {v!r}; expected a "
|
|
146
|
+
"non-empty string"
|
|
147
|
+
)
|
|
148
|
+
return v
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _parse_node(d: dict, tree_strain_field: str, branch_length: str) -> TreeNode:
|
|
152
|
+
children_dicts = d.get("children", [])
|
|
153
|
+
branch_value = _resolve_branch_length(d, branch_length)
|
|
154
|
+
if children_dicts:
|
|
155
|
+
# Internal node: use the Auspice top-level name (always present).
|
|
156
|
+
name = d.get("name") or "<internal>"
|
|
157
|
+
else:
|
|
158
|
+
# Tip: resolve via tree_strain_field.
|
|
159
|
+
name = _resolve_tip_strain(d, tree_strain_field)
|
|
160
|
+
children = [
|
|
161
|
+
_parse_node(c, tree_strain_field, branch_length) for c in children_dicts
|
|
162
|
+
]
|
|
163
|
+
return TreeNode(name=name, x=float(branch_value), children=children)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _resolve_branch_length(node_dict: dict, branch_length: str) -> float:
|
|
167
|
+
"""Pull the branch-length value out of a node's node_attrs."""
|
|
168
|
+
attrs = node_dict.get("node_attrs", {})
|
|
169
|
+
if branch_length == "div":
|
|
170
|
+
v = attrs.get("div")
|
|
171
|
+
if v is None:
|
|
172
|
+
raise ValueError(
|
|
173
|
+
f"tree node {node_dict.get('name', '?')!r} has no "
|
|
174
|
+
"node_attrs.div (required by branch_length='div')."
|
|
175
|
+
)
|
|
176
|
+
return v
|
|
177
|
+
# branch_length == "num_date"
|
|
178
|
+
nd = attrs.get("num_date")
|
|
179
|
+
if not isinstance(nd, dict) or "value" not in nd:
|
|
180
|
+
raise ValueError(
|
|
181
|
+
f"tree node {node_dict.get('name', '?')!r} has no "
|
|
182
|
+
"node_attrs.num_date.value (required by branch_length='num_date')."
|
|
183
|
+
)
|
|
184
|
+
return nd["value"]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _prune_tree_to(root: TreeNode, keep_strains: set[str]) -> TreeNode:
|
|
188
|
+
"""Return a new tree pruned to subtrees whose tips are in `keep_strains`.
|
|
189
|
+
|
|
190
|
+
Strict-flavor pruning per the Phase 2 plan:
|
|
191
|
+
- Drop every tip whose `name` is not in `keep_strains`.
|
|
192
|
+
- Drop every internal node whose entire subtree was dropped.
|
|
193
|
+
- Collapse single-child internals: when an internal node has only
|
|
194
|
+
one surviving child after pruning, the kept child replaces it.
|
|
195
|
+
Because each `TreeNode.x` is the *absolute* divergence from the
|
|
196
|
+
original root (not a delta from its parent), no branch-length
|
|
197
|
+
summation is needed — the kept node's `x` already reflects the
|
|
198
|
+
correct distance from any further-up ancestor. (For `num_date`
|
|
199
|
+
once Phase 2h adds it, the same holds: dates are absolute
|
|
200
|
+
positions.)
|
|
201
|
+
- Re-root to the LCA of the kept tips: if the original root has
|
|
202
|
+
only one kept-child path, the recursive case collapses upward
|
|
203
|
+
until a node with ≥2 surviving subtrees is reached, which becomes
|
|
204
|
+
the new root.
|
|
205
|
+
|
|
206
|
+
Raises ValueError if the resulting tree has no tips (i.e. `keep_strains`
|
|
207
|
+
didn't overlap any tip's name).
|
|
208
|
+
"""
|
|
209
|
+
pruned = _prune_recursive(root, keep_strains)
|
|
210
|
+
if pruned is None:
|
|
211
|
+
raise ValueError(
|
|
212
|
+
"after pruning, no tips remain; keep_strains has no overlap "
|
|
213
|
+
"with the tree's tip names"
|
|
214
|
+
)
|
|
215
|
+
return pruned
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _prune_recursive(node: TreeNode, keep_strains: set[str]) -> TreeNode | None:
|
|
219
|
+
"""Return a copy of `node` pruned to kept descendants, or None if no
|
|
220
|
+
descendants are kept.
|
|
221
|
+
|
|
222
|
+
Single-child internals are collapsed by returning the surviving child
|
|
223
|
+
directly — its absolute `x` is preserved, so root-to-tip distances
|
|
224
|
+
survive unchanged.
|
|
225
|
+
"""
|
|
226
|
+
if node.is_tip:
|
|
227
|
+
if node.name in keep_strains:
|
|
228
|
+
return TreeNode(name=node.name, x=node.x, children=[])
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
new_children: list[TreeNode] = []
|
|
232
|
+
for c in node.children:
|
|
233
|
+
kept = _prune_recursive(c, keep_strains)
|
|
234
|
+
if kept is not None:
|
|
235
|
+
new_children.append(kept)
|
|
236
|
+
|
|
237
|
+
if not new_children:
|
|
238
|
+
return None
|
|
239
|
+
if len(new_children) == 1:
|
|
240
|
+
# Collapse this single-child internal into its child.
|
|
241
|
+
return new_children[0]
|
|
242
|
+
return TreeNode(name=node.name, x=node.x, children=new_children)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def tips(root: TreeNode) -> Iterator[TreeNode]:
|
|
246
|
+
"""Yield tips of the tree in left-to-right (pre-order) traversal."""
|
|
247
|
+
if root.is_tip:
|
|
248
|
+
yield root
|
|
249
|
+
else:
|
|
250
|
+
for c in root.children:
|
|
251
|
+
yield from tips(c)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def layout(root: TreeNode) -> list[TreeNode]:
|
|
255
|
+
"""Assign `y` to every node in the tree and return tips in order.
|
|
256
|
+
|
|
257
|
+
Tips get integer y indices 0..N-1 in left-to-right order. Internal nodes
|
|
258
|
+
get the midpoint of their immediate children's y values.
|
|
259
|
+
"""
|
|
260
|
+
tip_list = list(tips(root))
|
|
261
|
+
if not tip_list:
|
|
262
|
+
raise ValueError("tree has no tips")
|
|
263
|
+
for i, tip in enumerate(tip_list):
|
|
264
|
+
tip.y = float(i)
|
|
265
|
+
_assign_internal_y(root)
|
|
266
|
+
return tip_list
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _assign_internal_y(node: TreeNode) -> float:
|
|
270
|
+
if node.is_tip:
|
|
271
|
+
assert node.y is not None
|
|
272
|
+
return node.y
|
|
273
|
+
ys = [_assign_internal_y(c) for c in node.children]
|
|
274
|
+
node.y = (min(ys) + max(ys)) / 2.0
|
|
275
|
+
return node.y
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def segments(root: TreeNode) -> pd.DataFrame:
|
|
279
|
+
"""Build a DataFrame of line segments for drawing the tree.
|
|
280
|
+
|
|
281
|
+
Returns columns `x`, `x2`, `y`, `y2`. Each row is one segment:
|
|
282
|
+
|
|
283
|
+
- For each internal node, one vertical connector from the topmost to the
|
|
284
|
+
bottommost child (x == x2 == node.x).
|
|
285
|
+
- For each non-root node, one horizontal branch from its parent's x to its
|
|
286
|
+
own x at its own y (y == y2).
|
|
287
|
+
|
|
288
|
+
Assumes :func:`layout` has already been called on `root`.
|
|
289
|
+
"""
|
|
290
|
+
if root.y is None:
|
|
291
|
+
raise ValueError("call layout(root) before segments(root)")
|
|
292
|
+
|
|
293
|
+
rows: list[dict] = []
|
|
294
|
+
|
|
295
|
+
def walk(node: TreeNode) -> None:
|
|
296
|
+
if node.is_tip:
|
|
297
|
+
return
|
|
298
|
+
child_ys = [c.y for c in node.children]
|
|
299
|
+
rows.append(
|
|
300
|
+
{"x": node.x, "x2": node.x, "y": min(child_ys), "y2": max(child_ys)}
|
|
301
|
+
)
|
|
302
|
+
for c in node.children:
|
|
303
|
+
rows.append({"x": node.x, "x2": c.x, "y": c.y, "y2": c.y})
|
|
304
|
+
walk(c)
|
|
305
|
+
|
|
306
|
+
walk(root)
|
|
307
|
+
return pd.DataFrame(rows, columns=["x", "x2", "y", "y2"])
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""`tree-annotated-plot` command-line entry point.
|
|
2
|
+
|
|
3
|
+
The CLI's --tree, --chart, --output options are hand-written (they are
|
|
4
|
+
CLI-specific data inputs without a config-object equivalent: their
|
|
5
|
+
*types* differ between Python and CLI surfaces, even though their
|
|
6
|
+
*descriptions* are single-sourced as `_config.{TREE,CHART,OUTPUT}_DESCRIPTION`).
|
|
7
|
+
Every other option is **auto-generated** from `PlotConfig` so its
|
|
8
|
+
`--help` text comes from the same `Annotated[T, "<description>"]`
|
|
9
|
+
metadata that documents the Python `tree_annotated_plot.plot` function. Adding a new
|
|
10
|
+
parameter takes one edit to `_config.py`; both surfaces pick it up.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import dataclasses
|
|
16
|
+
import types
|
|
17
|
+
import typing
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Literal, get_args, get_origin, get_type_hints
|
|
20
|
+
|
|
21
|
+
import click
|
|
22
|
+
|
|
23
|
+
from ._config import (
|
|
24
|
+
CHART_DESCRIPTION,
|
|
25
|
+
OUTPUT_DESCRIPTION,
|
|
26
|
+
TREE_DESCRIPTION,
|
|
27
|
+
PlotConfig,
|
|
28
|
+
)
|
|
29
|
+
from ._plot import _build
|
|
30
|
+
|
|
31
|
+
_SCALAR_CLICK_TYPE = {int: click.INT, float: click.FLOAT, str: click.STRING}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _option_for_field(field: dataclasses.Field, hints: dict) -> Any:
|
|
35
|
+
"""Build a `click.option(...)` decorator from a PlotConfig field.
|
|
36
|
+
|
|
37
|
+
Handles every field shape we use:
|
|
38
|
+
- bool (with default) → `--flag/--no-flag` dual form.
|
|
39
|
+
- Literal[...] → `click.Choice([...])`, required if no default.
|
|
40
|
+
- X | None → unwraps to X with default None. Literal | None becomes
|
|
41
|
+
a Choice with default None.
|
|
42
|
+
- int / float / str → click.INT / click.FLOAT / click.STRING.
|
|
43
|
+
|
|
44
|
+
Required fields (no default) become `required=True`.
|
|
45
|
+
"""
|
|
46
|
+
annotated = hints[field.name]
|
|
47
|
+
real_type = get_args(annotated)[0]
|
|
48
|
+
description = annotated.__metadata__[0]
|
|
49
|
+
|
|
50
|
+
cli_name = "--" + field.name.replace("_", "-")
|
|
51
|
+
has_default = field.default is not dataclasses.MISSING
|
|
52
|
+
|
|
53
|
+
# Bool → dual flag.
|
|
54
|
+
if real_type is bool:
|
|
55
|
+
return click.option(
|
|
56
|
+
f"{cli_name}/--no-{field.name.replace('_', '-')}",
|
|
57
|
+
default=field.default,
|
|
58
|
+
help=description,
|
|
59
|
+
show_default=True,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Literal → Choice.
|
|
63
|
+
if get_origin(real_type) is Literal:
|
|
64
|
+
choices = [str(c) for c in get_args(real_type)]
|
|
65
|
+
kwargs: dict[str, Any] = {
|
|
66
|
+
"type": click.Choice(choices),
|
|
67
|
+
"help": description,
|
|
68
|
+
"show_default": has_default,
|
|
69
|
+
}
|
|
70
|
+
if has_default:
|
|
71
|
+
kwargs["default"] = field.default
|
|
72
|
+
else:
|
|
73
|
+
kwargs["required"] = True
|
|
74
|
+
return click.option(cli_name, **kwargs)
|
|
75
|
+
|
|
76
|
+
# Optional / Union — unwrap to the non-None branch.
|
|
77
|
+
origin = get_origin(real_type)
|
|
78
|
+
if origin in (typing.Union, types.UnionType):
|
|
79
|
+
non_none = [a for a in get_args(real_type) if a is not type(None)]
|
|
80
|
+
if len(non_none) == 1:
|
|
81
|
+
inner = non_none[0]
|
|
82
|
+
if get_origin(inner) is Literal:
|
|
83
|
+
choices = [str(c) for c in get_args(inner)]
|
|
84
|
+
return click.option(
|
|
85
|
+
cli_name,
|
|
86
|
+
type=click.Choice(choices),
|
|
87
|
+
default=field.default if has_default else None,
|
|
88
|
+
help=description,
|
|
89
|
+
show_default=True,
|
|
90
|
+
)
|
|
91
|
+
return click.option(
|
|
92
|
+
cli_name,
|
|
93
|
+
type=_SCALAR_CLICK_TYPE.get(inner, click.STRING),
|
|
94
|
+
default=field.default if has_default else None,
|
|
95
|
+
help=description,
|
|
96
|
+
show_default=True,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# Plain scalar.
|
|
100
|
+
kwargs = {
|
|
101
|
+
"type": _SCALAR_CLICK_TYPE.get(real_type, click.STRING),
|
|
102
|
+
"help": description,
|
|
103
|
+
"show_default": has_default,
|
|
104
|
+
}
|
|
105
|
+
if has_default:
|
|
106
|
+
kwargs["default"] = field.default
|
|
107
|
+
else:
|
|
108
|
+
kwargs["required"] = True
|
|
109
|
+
return click.option(cli_name, **kwargs)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _stack_config_options(command: Any) -> Any:
|
|
113
|
+
"""Apply one `click.option(...)` per PlotConfig field to `command`.
|
|
114
|
+
|
|
115
|
+
`click.option(...)` invoked on an already-built `Command` *appends*
|
|
116
|
+
to `command.params`, so iterating fields in declaration order gives
|
|
117
|
+
--help in declaration order (required fields first, since
|
|
118
|
+
`chart_strain_field` and `tree_strain_field` lead PlotConfig).
|
|
119
|
+
"""
|
|
120
|
+
hints = get_type_hints(PlotConfig, include_extras=True)
|
|
121
|
+
for field in dataclasses.fields(PlotConfig):
|
|
122
|
+
command = _option_for_field(field, hints)(command)
|
|
123
|
+
return command
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@_stack_config_options
|
|
127
|
+
@click.command(
|
|
128
|
+
name="tree-annotated-plot",
|
|
129
|
+
help=(
|
|
130
|
+
"Plot a phylogenetic tree alongside a Vega-Lite chart whose "
|
|
131
|
+
"categorical axis is reordered to match the tree's tip order. "
|
|
132
|
+
"Save the result as HTML / JSON / PNG / SVG / PDF "
|
|
133
|
+
"(format is dispatched on --output's extension)."
|
|
134
|
+
),
|
|
135
|
+
)
|
|
136
|
+
@click.option(
|
|
137
|
+
"--tree",
|
|
138
|
+
"tree_path",
|
|
139
|
+
required=True,
|
|
140
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
141
|
+
help=TREE_DESCRIPTION,
|
|
142
|
+
)
|
|
143
|
+
@click.option(
|
|
144
|
+
"--chart",
|
|
145
|
+
"chart_path",
|
|
146
|
+
required=True,
|
|
147
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
148
|
+
help=CHART_DESCRIPTION,
|
|
149
|
+
)
|
|
150
|
+
@click.option(
|
|
151
|
+
"--output",
|
|
152
|
+
"output_path",
|
|
153
|
+
required=True,
|
|
154
|
+
type=click.Path(dir_okay=False, path_type=Path),
|
|
155
|
+
help=OUTPUT_DESCRIPTION,
|
|
156
|
+
)
|
|
157
|
+
def main(tree_path: Path, chart_path: Path, output_path: Path, **kwargs: Any) -> None:
|
|
158
|
+
"""Top-level CLI entry point."""
|
|
159
|
+
config = PlotConfig(**kwargs)
|
|
160
|
+
out = _build(tree_path, chart_path, config)
|
|
161
|
+
out.save(str(output_path))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
if __name__ == "__main__":
|
|
165
|
+
main()
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tree-annotated-plot
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Annotate the axis of an Altair / Vega-Lite plot with a phylogenetic tree.
|
|
5
|
+
Project-URL: Documentation, https://jbloomlab.github.io/tree-annotated-plot/
|
|
6
|
+
Project-URL: Homepage, https://github.com/jbloomlab/tree-annotated-plot
|
|
7
|
+
Project-URL: Repository, https://github.com/jbloomlab/tree-annotated-plot
|
|
8
|
+
Project-URL: Issues, https://github.com/jbloomlab/tree-annotated-plot/issues
|
|
9
|
+
Author-email: Jesse Bloom <jbloom@fredhutch.org>
|
|
10
|
+
License: MIT License
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
28
|
+
SOFTWARE.
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
31
|
+
Classifier: Operating System :: OS Independent
|
|
32
|
+
Classifier: Programming Language :: Python :: 3
|
|
33
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
34
|
+
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
35
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
36
|
+
Requires-Python: >=3.13
|
|
37
|
+
Requires-Dist: altair<7,>=6.0
|
|
38
|
+
Requires-Dist: click>=8.0
|
|
39
|
+
Requires-Dist: pandas>=2.0
|
|
40
|
+
Requires-Dist: vl-convert-python>=1.9
|
|
41
|
+
Provides-Extra: dev
|
|
42
|
+
Requires-Dist: black>=24; extra == 'dev'
|
|
43
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
44
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
45
|
+
Provides-Extra: docs
|
|
46
|
+
Requires-Dist: mkdocs-click>=0.8; extra == 'docs'
|
|
47
|
+
Requires-Dist: mkdocs-material>=9.5; extra == 'docs'
|
|
48
|
+
Requires-Dist: mkdocs>=1.6; extra == 'docs'
|
|
49
|
+
Requires-Dist: mkdocstrings[python]>=0.27; extra == 'docs'
|
|
50
|
+
Description-Content-Type: text/markdown
|
|
51
|
+
|
|
52
|
+
# `tree-annotated-plot`: annotate the axis of an Altair / Vega-Lite plot with a phylogenetic tree
|
|
53
|
+
|
|
54
|
+
[](https://opensource.org/licenses/MIT)
|
|
55
|
+
[](https://github.com/psf/black)
|
|
56
|
+
[](https://github.com/astral-sh/ruff)
|
|
57
|
+
|
|
58
|
+
This is a Python package from the [Bloom lab](https://jbloomlab.org/) that allows you to combine an Altair / Vega-Lite plot with a Nextstrain JSON of a phylogenetic tree so that the tree is aligned to annotate strains on the axis of the plot.
|
|
59
|
+
See [https://jbloomlab.github.io/tree-annotated-plot/](https://jbloomlab.github.io/tree-annotated-plot/) for detailed documentation.
|
|
60
|
+
|
|
61
|
+
> **Note:** charts must be saved from altair 6+ (Vega-Lite v6). Older specs raise by default; pass `--no-strict-version` (CLI) or `strict_version=False` (Python) to override at your own risk. See the [docs](https://jbloomlab.github.io/tree-annotated-plot/) for details.
|
|
62
|
+
|
|
63
|
+
## Notes for developing the package
|
|
64
|
+
|
|
65
|
+
### Installation (development)
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
python3 -m venv .venv
|
|
69
|
+
source .venv/bin/activate
|
|
70
|
+
pip install -e ".[dev,docs]"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
[pyproject.toml](pyproject.toml) is the canonical source for the supported Python
|
|
74
|
+
version and runtime dependencies.
|
|
75
|
+
|
|
76
|
+
### Visual verification after code changes
|
|
77
|
+
|
|
78
|
+
This package has automated tests for chart **structure** (encodings,
|
|
79
|
+
sort order, tip reconciliation, scale-bar arithmetic, etc.), but
|
|
80
|
+
**not** for chart **appearance** — there are no image-snapshot
|
|
81
|
+
regression tests, because rendering is sensitive to fonts,
|
|
82
|
+
`vl-convert-python` versions, and platform differences in ways that
|
|
83
|
+
make pixel-level comparison flaky in practice.
|
|
84
|
+
|
|
85
|
+
So the discipline is manual: any time you change code that affects
|
|
86
|
+
rendering (`src/tree_annotated_plot/_plot.py`,
|
|
87
|
+
`src/tree_annotated_plot/_tree.py`, the example scripts under
|
|
88
|
+
`examples/`, or anything they touch), run the full check below and
|
|
89
|
+
**eyeball every example** before considering the change done.
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
bash scripts/check.sh # lint + format + pytest (all 100+ tests)
|
|
93
|
+
bash scripts/build_docs.sh # regenerates docs/images + docs/charts and runs mkdocs --strict
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Then open `site/examples.html` in a browser and, for each example:
|
|
97
|
+
|
|
98
|
+
1. Look at the embedded SVG screenshot. Does it match what the
|
|
99
|
+
feature you changed should produce? Are tip rows still aligned with
|
|
100
|
+
the chart rows? Is the tree's tip-end facing the chart? Is the
|
|
101
|
+
scale bar (if enabled) centered and labeled correctly?
|
|
102
|
+
2. Click "Open the interactive chart →". Hover tips to confirm
|
|
103
|
+
tooltips fire; if there's a cohort selector (Kikawa examples),
|
|
104
|
+
toggle it; pan/zoom if relevant.
|
|
105
|
+
3. Compare against the
|
|
106
|
+
[deployed site](https://jbloomlab.github.io/tree-annotated-plot/)
|
|
107
|
+
(which still reflects `main` before your change) for what "good"
|
|
108
|
+
looks like.
|
|
109
|
+
|
|
110
|
+
If something visibly regresses, fix it before pushing — there's no
|
|
111
|
+
CI gate that will catch a visual regression for you.
|
|
112
|
+
|
|
113
|
+
### Continuous integration
|
|
114
|
+
|
|
115
|
+
[`.github/workflows/ci.yml`](.github/workflows/ci.yml) runs on every
|
|
116
|
+
push to `main` and every pull request: it installs the package, fetches
|
|
117
|
+
the upstream Kikawa Auspice JSONs (so the real-data tests actually
|
|
118
|
+
exercise rather than skip), builds the Kikawa titer chart specs, and
|
|
119
|
+
runs `ruff check`, `black --check`, and `pytest tests/`. This mirrors
|
|
120
|
+
[`scripts/check.sh`](scripts/check.sh) — green locally should mean
|
|
121
|
+
green in CI.
|
|
122
|
+
|
|
123
|
+
A separate workflow,
|
|
124
|
+
[`.github/workflows/docs.yml`](.github/workflows/docs.yml), builds the
|
|
125
|
+
docs site on every push to `main` and every PR (catching broken
|
|
126
|
+
mkdocstrings refs / missing images / unresolved cross-links before
|
|
127
|
+
merge), and additionally deploys `site/` to GitHub Pages on push to
|
|
128
|
+
`main`.
|
|
129
|
+
|
|
130
|
+
A third workflow,
|
|
131
|
+
[`.github/workflows/release.yml`](.github/workflows/release.yml),
|
|
132
|
+
builds the sdist + wheel and publishes them to PyPI when a `v*` tag
|
|
133
|
+
is pushed (see "Releasing a new version" below).
|
|
134
|
+
|
|
135
|
+
### Releasing a new version
|
|
136
|
+
|
|
137
|
+
Releases are fully automated by tag push. Trusted publishing (OIDC)
|
|
138
|
+
means **no API token or password is stored in the repo** — PyPI
|
|
139
|
+
authenticates the workflow via its repo + workflow filename +
|
|
140
|
+
environment.
|
|
141
|
+
|
|
142
|
+
One-time setup (only needed before the first release):
|
|
143
|
+
|
|
144
|
+
1. Reserve `tree-annotated-plot` on [PyPI](https://pypi.org/) (first
|
|
145
|
+
successful publish creates it; or claim it manually via
|
|
146
|
+
`pip install build && python -m build && twine upload dist/*` from a
|
|
147
|
+
maintainer machine).
|
|
148
|
+
2. On PyPI, go to the project → "Publishing" tab → "Add a new pending
|
|
149
|
+
publisher". Fill in: owner `jbloomlab`, repository
|
|
150
|
+
`tree-annotated-plot`, workflow filename `release.yml`, environment
|
|
151
|
+
`pypi`.
|
|
152
|
+
3. (Optional but recommended) On GitHub, repo Settings → Environments
|
|
153
|
+
→ New environment → name `pypi`. Add yourself as a required
|
|
154
|
+
reviewer if you want a manual approval gate before each release
|
|
155
|
+
lands on PyPI.
|
|
156
|
+
|
|
157
|
+
Per-release recipe (every time):
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
# 1. Edit pyproject.toml and bump `version = "X.Y.Z"`.
|
|
161
|
+
# 2. Commit and tag (the workflow verifies they match before publishing).
|
|
162
|
+
git commit -am "release vX.Y.Z"
|
|
163
|
+
git tag vX.Y.Z
|
|
164
|
+
git push && git push --tags
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The `release` workflow then builds, verifies tag ⇋ version match, and
|
|
168
|
+
publishes to PyPI.
|
|
169
|
+
|
|
170
|
+
### Documentation
|
|
171
|
+
The docs are at
|
|
172
|
+
[https://jbloomlab.github.io/tree-annotated-plot/](https://jbloomlab.github.io/tree-annotated-plot/).
|
|
173
|
+
They are auto-deployed by GitHub Actions on every push to `main` —
|
|
174
|
+
[`.github/workflows/docs.yml`](.github/workflows/docs.yml) installs
|
|
175
|
+
the package + docs extras, runs
|
|
176
|
+
[`scripts/generate_docs_assets.py`](scripts/generate_docs_assets.py)
|
|
177
|
+
to render images and standalone interactive HTML for each
|
|
178
|
+
example, then `mkdocs build --strict`, and uploads `site/` as a
|
|
179
|
+
GitHub Pages artifact.
|
|
180
|
+
|
|
181
|
+
### Building documentation locally
|
|
182
|
+
|
|
183
|
+
For day-to-day editing of the docs, use MkDocs's live-reload server
|
|
184
|
+
(saves go straight to your browser):
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
mkdocs serve # http://localhost:8000
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
To do this on the Fred Hutch remote server, do:
|
|
191
|
+
```bash
|
|
192
|
+
mkdocs serve -a $(hostname -i | awk '{print $1}'):$(fhfreeport)
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
For the strict build that matches CI (catches broken
|
|
196
|
+
[`mkdocstrings`](https://mkdocstrings.github.io/) refs, missing
|
|
197
|
+
images, unresolved cross-links):
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
bash scripts/build_docs.sh
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
This first runs `scripts/generate_docs_assets.py` (so the example
|
|
204
|
+
SVGs and interactive HTMLs exist before MkDocs reads `docs/`), then
|
|
205
|
+
`mkdocs build --strict`. Output lands in `site/`.
|
|
206
|
+
|
|
207
|
+
### Adding a new example
|
|
208
|
+
|
|
209
|
+
The docs follow a single template per example: a short motivation,
|
|
210
|
+
an embedded SVG screenshot, a link to the fully-interactive
|
|
211
|
+
standalone HTML, and the commands to reproduce (CLI form first,
|
|
212
|
+
Python API form second). [`docs/examples.md`](docs/examples.md)
|
|
213
|
+
shows the existing shape. To add another:
|
|
214
|
+
|
|
215
|
+
1. Drop a runnable script into `examples/`. Keep helpers at module
|
|
216
|
+
level (callable from outside) so the asset script can import them.
|
|
217
|
+
2. Add a clause to `scripts/generate_docs_assets.py` that imports
|
|
218
|
+
the script and calls `tree_annotated_plot.plot(...)` followed by saving both
|
|
219
|
+
`<name>.svg` (into `docs/images/`) and `<name>.html` (into
|
|
220
|
+
`docs/charts/`). Both directories are gitignored — only the
|
|
221
|
+
asset script writes there.
|
|
222
|
+
3. Add a section to `docs/examples.md` matching the template.
|
|
223
|
+
4. Run `bash scripts/build_docs.sh` and open `site/examples.html`
|
|
224
|
+
to verify. Push when happy; the deployed site updates on the
|
|
225
|
+
next CI run.
|