tree-annotated-plot 0.2.2__tar.gz → 0.3.0__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.
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/CHANGELOG.md +19 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/PKG-INFO +5 -3
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/README.md +1 -1
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/docs/index.md +2 -2
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/pyproject.toml +6 -4
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/src/tree_annotated_plot/_config.py +8 -3
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/src/tree_annotated_plot/_plot.py +101 -6
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/src/tree_annotated_plot/cli.py +1 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/tests/test_cli.py +8 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/tests/test_reconciliation.py +94 -3
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/.github/workflows/docs.yml +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/.github/workflows/release.yml +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/.github/workflows/tests.yml +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/.gitignore +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/CLAUDE.md +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/LICENSE +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/docs/cli.md +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/docs/examples.md +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/docs/python-api.md +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/examples/fetch_auspice_data.py +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/examples/flu-seqneut-2025to2026_titer_charts.py +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/examples/synthetic_example.py +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/mkdocs.yml +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/scripts/build_docs.sh +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/scripts/check.sh +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/scripts/generate_docs_assets.py +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/src/tree_annotated_plot/__init__.py +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/src/tree_annotated_plot/_color.py +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/src/tree_annotated_plot/_tree.py +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/tests/test_appearance_tuning.py +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/tests/test_branch_length.py +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/tests/test_chart_loader.py +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/tests/test_color_tree.py +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/tests/test_examples_run.py +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/tests/test_introspection.py +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/tests/test_label_connection.py +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/tests/test_line_order.py +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/tests/test_plot.py +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/tests/test_plot_config.py +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/tests/test_real_data.py +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/tests/test_scale_bar.py +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/tests/test_style.py +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/tests/test_tree_location.py +0 -0
|
@@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.3.0] - 2026-05-19
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `prune_chart_to_tree` (default `False`): when on, chart rows whose
|
|
15
|
+
`chart_strain_field` value isn't a tree tip are filtered out before
|
|
16
|
+
drawing. CLI form:
|
|
17
|
+
`--prune-chart-to-tree / --no-prune-chart-to-tree`.
|
|
18
|
+
([#6](https://github.com/jbloomlab/tree-annotated-plot/issues/6))
|
|
19
|
+
- `-h` is accepted as a short alias for `--help` on the
|
|
20
|
+
`tree-annotated-plot` CLI.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- Minimum supported Python lowered from 3.13 to 3.11. CI still
|
|
25
|
+
runs on 3.13.
|
|
26
|
+
|
|
8
27
|
## [0.2.2] - 2026-05-09
|
|
9
28
|
|
|
10
29
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tree-annotated-plot
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Annotate the axis of an Altair / Vega-Lite plot with a phylogenetic tree.
|
|
5
5
|
Project-URL: Documentation, https://jbloomlab.github.io/tree-annotated-plot/
|
|
6
6
|
Project-URL: Homepage, https://github.com/jbloomlab/tree-annotated-plot
|
|
@@ -30,10 +30,12 @@ License-File: LICENSE
|
|
|
30
30
|
Classifier: License :: OSI Approved :: MIT License
|
|
31
31
|
Classifier: Operating System :: OS Independent
|
|
32
32
|
Classifier: Programming Language :: Python :: 3
|
|
33
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
34
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
33
35
|
Classifier: Programming Language :: Python :: 3.13
|
|
34
36
|
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
35
37
|
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
36
|
-
Requires-Python: >=3.
|
|
38
|
+
Requires-Python: >=3.11
|
|
37
39
|
Requires-Dist: altair<7,>=6.0
|
|
38
40
|
Requires-Dist: click>=8.0
|
|
39
41
|
Requires-Dist: pandas>=2.0
|
|
@@ -66,7 +68,7 @@ See [https://jbloomlab.github.io/tree-annotated-plot/](https://jbloomlab.github.
|
|
|
66
68
|
|
|
67
69
|
## Installation
|
|
68
70
|
|
|
69
|
-
Released on [PyPI](https://pypi.org/project/tree-annotated-plot/). Requires Python 3.
|
|
71
|
+
Released on [PyPI](https://pypi.org/project/tree-annotated-plot/). Requires Python 3.11+.
|
|
70
72
|
|
|
71
73
|
```bash
|
|
72
74
|
pip install tree-annotated-plot
|
|
@@ -15,7 +15,7 @@ See [https://jbloomlab.github.io/tree-annotated-plot/](https://jbloomlab.github.
|
|
|
15
15
|
|
|
16
16
|
## Installation
|
|
17
17
|
|
|
18
|
-
Released on [PyPI](https://pypi.org/project/tree-annotated-plot/). Requires Python 3.
|
|
18
|
+
Released on [PyPI](https://pypi.org/project/tree-annotated-plot/). Requires Python 3.11+.
|
|
19
19
|
|
|
20
20
|
```bash
|
|
21
21
|
pip install tree-annotated-plot
|
|
@@ -27,7 +27,7 @@ sit on by default (left for y-axis, bottom for x-axis).
|
|
|
27
27
|
## Installation
|
|
28
28
|
|
|
29
29
|
Released on [PyPI](https://pypi.org/project/tree-annotated-plot/).
|
|
30
|
-
Requires Python 3.
|
|
30
|
+
Requires Python 3.11+.
|
|
31
31
|
|
|
32
32
|
```bash
|
|
33
33
|
pip install tree-annotated-plot
|
|
@@ -36,7 +36,7 @@ pip install tree-annotated-plot
|
|
|
36
36
|
To pin a specific version:
|
|
37
37
|
|
|
38
38
|
```bash
|
|
39
|
-
pip install tree-annotated-plot==0.0
|
|
39
|
+
pip install tree-annotated-plot==0.3.0
|
|
40
40
|
```
|
|
41
41
|
|
|
42
42
|
To install the bleeding edge directly from the
|
|
@@ -4,15 +4,17 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tree-annotated-plot"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Annotate the axis of an Altair / Vega-Lite plot with a phylogenetic tree."
|
|
9
9
|
readme = "README.md"
|
|
10
|
-
requires-python = ">=3.
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
11
|
license = { file = "LICENSE" }
|
|
12
12
|
authors = [{ name = "Jesse Bloom", email = "jbloom@fredhutch.org" }]
|
|
13
13
|
classifiers = [
|
|
14
14
|
"License :: OSI Approved :: MIT License",
|
|
15
15
|
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
16
18
|
"Programming Language :: Python :: 3.13",
|
|
17
19
|
"Operating System :: OS Independent",
|
|
18
20
|
"Topic :: Scientific/Engineering :: Bio-Informatics",
|
|
@@ -52,7 +54,7 @@ packages = ["src/tree_annotated_plot"]
|
|
|
52
54
|
dev-mode-dirs = ["src"]
|
|
53
55
|
|
|
54
56
|
[tool.black]
|
|
55
|
-
target-version = ["
|
|
57
|
+
target-version = ["py311"]
|
|
56
58
|
|
|
57
59
|
[tool.ruff]
|
|
58
|
-
target-version = "
|
|
60
|
+
target-version = "py311"
|
|
@@ -109,9 +109,14 @@ class PlotConfig:
|
|
|
109
109
|
"When off (default), tree tips not present in the chart's strain "
|
|
110
110
|
"set are a fatal error. When on, those tips (and any internal "
|
|
111
111
|
"nodes whose subtrees become empty) are dropped before drawing, "
|
|
112
|
-
"with single-child internals collapsed into their kept child.
|
|
113
|
-
|
|
114
|
-
|
|
112
|
+
"with single-child internals collapsed into their kept child.",
|
|
113
|
+
] = False
|
|
114
|
+
|
|
115
|
+
prune_chart_to_tree: Annotated[
|
|
116
|
+
bool,
|
|
117
|
+
"When off (default), chart strains not present in the tree are a "
|
|
118
|
+
"fatal error. When on, chart rows whose `chart_strain_field` "
|
|
119
|
+
"value isn't a tree tip are filtered out before drawing.",
|
|
115
120
|
] = False
|
|
116
121
|
|
|
117
122
|
strict_version: Annotated[
|
|
@@ -55,6 +55,7 @@ def plot(
|
|
|
55
55
|
scale_bar: bool = False,
|
|
56
56
|
branch_length_units: str | None = None,
|
|
57
57
|
prune_tree_to_chart: bool = False,
|
|
58
|
+
prune_chart_to_tree: bool = False,
|
|
58
59
|
strict_version: bool = True,
|
|
59
60
|
connect_leader_to_label: bool = False,
|
|
60
61
|
strain_label_font_size: float = 10.0,
|
|
@@ -82,6 +83,7 @@ def plot(
|
|
|
82
83
|
scale_bar=scale_bar,
|
|
83
84
|
branch_length_units=branch_length_units,
|
|
84
85
|
prune_tree_to_chart=prune_tree_to_chart,
|
|
86
|
+
prune_chart_to_tree=prune_chart_to_tree,
|
|
85
87
|
strict_version=strict_version,
|
|
86
88
|
connect_leader_to_label=connect_leader_to_label,
|
|
87
89
|
strain_label_font_size=strain_label_font_size,
|
|
@@ -168,12 +170,33 @@ def _build(
|
|
|
168
170
|
|
|
169
171
|
chart_strains = _extract_chart_strains(spec, axis_hits, config.chart_strain_field)
|
|
170
172
|
|
|
173
|
+
if config.prune_chart_to_tree and (set(chart_strains) - set(tip_names)):
|
|
174
|
+
_prune_chart_spec_to_strains(
|
|
175
|
+
spec,
|
|
176
|
+
chart_strain_field=config.chart_strain_field,
|
|
177
|
+
keep_strains=set(tip_names),
|
|
178
|
+
)
|
|
179
|
+
chart = alt.Chart.from_dict(spec)
|
|
180
|
+
chart_strains = _extract_chart_strains(
|
|
181
|
+
spec, axis_hits, config.chart_strain_field
|
|
182
|
+
)
|
|
183
|
+
if not chart_strains:
|
|
184
|
+
raise ValueError(
|
|
185
|
+
"prune_chart_to_tree=True dropped every chart row: no "
|
|
186
|
+
"chart strain matched any tree tip under "
|
|
187
|
+
f"chart_strain_field={config.chart_strain_field!r} / "
|
|
188
|
+
f"tree_strain_field={config.tree_strain_field!r}. Pruning "
|
|
189
|
+
"is meant for charts that bundle a superset of strains; "
|
|
190
|
+
"an empty intersection suggests a wrong field choice."
|
|
191
|
+
)
|
|
192
|
+
|
|
171
193
|
_reconcile_tips_and_strains(
|
|
172
194
|
tree_strains=tip_names,
|
|
173
195
|
chart_strains=chart_strains,
|
|
174
196
|
chart_strain_field=config.chart_strain_field,
|
|
175
197
|
tree_strain_field=config.tree_strain_field,
|
|
176
198
|
prune_tree_to_chart=config.prune_tree_to_chart,
|
|
199
|
+
prune_chart_to_tree=config.prune_chart_to_tree,
|
|
177
200
|
chart_spec=spec,
|
|
178
201
|
tree_source=tree,
|
|
179
202
|
)
|
|
@@ -641,6 +664,70 @@ def _extract_chart_strains(
|
|
|
641
664
|
return _extract_field_values_from_spec_data(spec, chart_strain_field)
|
|
642
665
|
|
|
643
666
|
|
|
667
|
+
def _prune_chart_spec_to_strains(
|
|
668
|
+
spec: dict, *, chart_strain_field: str, keep_strains: set[str]
|
|
669
|
+
) -> None:
|
|
670
|
+
"""Filter a Vega-Lite spec in place to drop rows outside `keep_strains`.
|
|
671
|
+
|
|
672
|
+
Mutates three kinds of structure:
|
|
673
|
+
- top-level `datasets` entries (each a list of row-dicts).
|
|
674
|
+
- inline `data.values` lists anywhere in the spec tree.
|
|
675
|
+
- explicit `sort` lists on encoding channels bound to
|
|
676
|
+
`chart_strain_field` (any other `sort` is left alone).
|
|
677
|
+
|
|
678
|
+
Rows that don't carry `chart_strain_field` at all are preserved
|
|
679
|
+
(we have no signal to drop them). URL-backed data raises — we
|
|
680
|
+
can't fetch + filter at plot time, mirroring `_extract_chart_strains`.
|
|
681
|
+
"""
|
|
682
|
+
|
|
683
|
+
def row_kept(row: Any) -> bool:
|
|
684
|
+
if not isinstance(row, dict):
|
|
685
|
+
return True
|
|
686
|
+
if chart_strain_field not in row:
|
|
687
|
+
return True
|
|
688
|
+
return row[chart_strain_field] in keep_strains
|
|
689
|
+
|
|
690
|
+
datasets = spec.get("datasets") if isinstance(spec, dict) else None
|
|
691
|
+
if isinstance(datasets, dict):
|
|
692
|
+
for name, rows in list(datasets.items()):
|
|
693
|
+
if isinstance(rows, list):
|
|
694
|
+
datasets[name] = [row for row in rows if row_kept(row)]
|
|
695
|
+
|
|
696
|
+
def walk(node: Any) -> None:
|
|
697
|
+
if isinstance(node, dict):
|
|
698
|
+
data = node.get("data")
|
|
699
|
+
if isinstance(data, dict):
|
|
700
|
+
if "url" in data:
|
|
701
|
+
raise ValueError(
|
|
702
|
+
f"chart references data via URL ({data['url']!r}); "
|
|
703
|
+
"URL data is not supported, so prune_chart_to_tree "
|
|
704
|
+
"cannot filter it. Materialize the data inline "
|
|
705
|
+
"(via alt.Chart(df) with a pandas DataFrame) before "
|
|
706
|
+
"saving the chart."
|
|
707
|
+
)
|
|
708
|
+
if "values" in data and isinstance(data["values"], list):
|
|
709
|
+
data["values"] = [row for row in data["values"] if row_kept(row)]
|
|
710
|
+
encoding = node.get("encoding")
|
|
711
|
+
if isinstance(encoding, dict):
|
|
712
|
+
for channel in encoding.values():
|
|
713
|
+
if not isinstance(channel, dict):
|
|
714
|
+
continue
|
|
715
|
+
if channel.get("field") != chart_strain_field:
|
|
716
|
+
continue
|
|
717
|
+
sort = channel.get("sort")
|
|
718
|
+
if isinstance(sort, list):
|
|
719
|
+
channel["sort"] = [s for s in sort if s in keep_strains]
|
|
720
|
+
for k, v in node.items():
|
|
721
|
+
if k in ("data", "datasets"):
|
|
722
|
+
continue
|
|
723
|
+
walk(v)
|
|
724
|
+
elif isinstance(node, list):
|
|
725
|
+
for item in node:
|
|
726
|
+
walk(item)
|
|
727
|
+
|
|
728
|
+
walk(spec)
|
|
729
|
+
|
|
730
|
+
|
|
644
731
|
def _extract_field_values_from_spec_data(spec: dict, field: str) -> list[str]:
|
|
645
732
|
"""Walk spec for inline / named data and return distinct values of `field`.
|
|
646
733
|
|
|
@@ -708,13 +795,16 @@ def _reconcile_tips_and_strains(
|
|
|
708
795
|
chart_strain_field: str,
|
|
709
796
|
tree_strain_field: str,
|
|
710
797
|
prune_tree_to_chart: bool,
|
|
798
|
+
prune_chart_to_tree: bool,
|
|
711
799
|
chart_spec: dict,
|
|
712
800
|
tree_source: Any,
|
|
713
801
|
) -> None:
|
|
714
802
|
"""Verify tree strains and chart strains are reconcilable.
|
|
715
803
|
|
|
716
804
|
Three asymmetries:
|
|
717
|
-
- chart strains not in tree →
|
|
805
|
+
- chart strains not in tree → fatal unless `prune_chart_to_tree=True`
|
|
806
|
+
(in which case the chart spec has already been pre-filtered upstream
|
|
807
|
+
and this set is expected to be empty by the time we get here).
|
|
718
808
|
- tree tips not in chart → fatal unless `prune_tree_to_chart=True`.
|
|
719
809
|
- (duplicate tree_strain_field values across tips → handled by the
|
|
720
810
|
separate `_check_no_duplicate_tip_strains`.)
|
|
@@ -728,7 +818,9 @@ def _reconcile_tips_and_strains(
|
|
|
728
818
|
chart_minus_tree = chart_set - tree_set
|
|
729
819
|
tree_minus_chart = tree_set - chart_set
|
|
730
820
|
|
|
731
|
-
|
|
821
|
+
chart_ok = not chart_minus_tree
|
|
822
|
+
tree_ok = not tree_minus_chart or prune_tree_to_chart
|
|
823
|
+
if chart_ok and tree_ok:
|
|
732
824
|
return
|
|
733
825
|
|
|
734
826
|
hints = _candidate_field_hints(
|
|
@@ -748,6 +840,7 @@ def _reconcile_tips_and_strains(
|
|
|
748
840
|
chart_minus_tree=chart_minus_tree,
|
|
749
841
|
tree_minus_chart=tree_minus_chart,
|
|
750
842
|
prune_tree_to_chart=prune_tree_to_chart,
|
|
843
|
+
prune_chart_to_tree=prune_chart_to_tree,
|
|
751
844
|
hints=hints,
|
|
752
845
|
)
|
|
753
846
|
)
|
|
@@ -762,14 +855,16 @@ def _format_strain_mismatch(
|
|
|
762
855
|
chart_minus_tree: set[str],
|
|
763
856
|
tree_minus_chart: set[str],
|
|
764
857
|
prune_tree_to_chart: bool,
|
|
858
|
+
prune_chart_to_tree: bool,
|
|
765
859
|
hints: list[str],
|
|
766
860
|
) -> str:
|
|
767
861
|
parts: list[str] = []
|
|
768
|
-
if chart_minus_tree:
|
|
862
|
+
if chart_minus_tree and not prune_chart_to_tree:
|
|
769
863
|
parts.append(
|
|
770
864
|
f"{len(chart_minus_tree)} chart strain(s) are not present in the "
|
|
771
|
-
"tree
|
|
772
|
-
"
|
|
865
|
+
"tree. Pass `prune_chart_to_tree=True` to drop the offending "
|
|
866
|
+
"chart rows automatically (use with care — this discards plot "
|
|
867
|
+
"data)."
|
|
773
868
|
)
|
|
774
869
|
if tree_minus_chart and not prune_tree_to_chart:
|
|
775
870
|
parts.append(
|
|
@@ -783,7 +878,7 @@ def _format_strain_mismatch(
|
|
|
783
878
|
)
|
|
784
879
|
parts.append("Sample chart_strain_field values: " f"{sorted(chart_strains)[:5]}")
|
|
785
880
|
parts.append("Sample tree_strain_field values: " f"{sorted(tree_strains)[:5]}")
|
|
786
|
-
if chart_minus_tree:
|
|
881
|
+
if chart_minus_tree and not prune_chart_to_tree:
|
|
787
882
|
parts.append(f"Sample chart-only values: {sorted(chart_minus_tree)[:5]}")
|
|
788
883
|
if tree_minus_chart and not prune_tree_to_chart:
|
|
789
884
|
parts.append(f"Sample tree-only values: {sorted(tree_minus_chart)[:5]}")
|
|
@@ -263,6 +263,7 @@ def _stack_config_options(command: Any) -> Any:
|
|
|
263
263
|
"Save the result as HTML / JSON / PNG / SVG / PDF "
|
|
264
264
|
"(format is dispatched on --output's extension)."
|
|
265
265
|
),
|
|
266
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
266
267
|
)
|
|
267
268
|
@click.option(
|
|
268
269
|
"--tree",
|
|
@@ -27,6 +27,14 @@ def test_help_lists_data_options() -> None:
|
|
|
27
27
|
assert "--output" in result.output
|
|
28
28
|
|
|
29
29
|
|
|
30
|
+
def test_short_help_flag_is_alias_for_long() -> None:
|
|
31
|
+
"""`-h` should produce identical output to `--help`."""
|
|
32
|
+
long_form = _runner().invoke(main, ["--help"])
|
|
33
|
+
short_form = _runner().invoke(main, ["-h"])
|
|
34
|
+
assert short_form.exit_code == 0
|
|
35
|
+
assert short_form.output == long_form.output
|
|
36
|
+
|
|
37
|
+
|
|
30
38
|
def test_help_lists_auto_generated_options() -> None:
|
|
31
39
|
"""A handful of PlotConfig-derived options should appear in --help."""
|
|
32
40
|
result = _runner().invoke(main, ["--help"])
|
|
@@ -73,7 +73,7 @@ def _chart_for_strains(strains: list[str], *, height: int = 200) -> alt.Chart:
|
|
|
73
73
|
)
|
|
74
74
|
|
|
75
75
|
|
|
76
|
-
# ---------- chart-not-in-tree (
|
|
76
|
+
# ---------- chart-not-in-tree (fatal unless prune_chart_to_tree) ----------
|
|
77
77
|
|
|
78
78
|
|
|
79
79
|
def test_chart_strain_not_in_tree_is_fatal_default() -> None:
|
|
@@ -90,9 +90,9 @@ def test_chart_strain_not_in_tree_is_fatal_default() -> None:
|
|
|
90
90
|
)
|
|
91
91
|
|
|
92
92
|
|
|
93
|
-
def
|
|
93
|
+
def test_chart_strain_not_in_tree_is_fatal_with_only_tree_prune() -> None:
|
|
94
94
|
"""`prune_tree_to_chart=True` only drops *tree* tips. A chart strain
|
|
95
|
-
not in the tree still raises —
|
|
95
|
+
not in the tree still raises — the two flags are orthogonal."""
|
|
96
96
|
chart = _chart_for_strains(["A1", "A2", "X"])
|
|
97
97
|
with pytest.raises(ValueError, match="not present in the tree"):
|
|
98
98
|
tree_annotated_plot.plot(
|
|
@@ -105,6 +105,97 @@ def test_chart_strain_not_in_tree_is_fatal_even_with_prune() -> None:
|
|
|
105
105
|
)
|
|
106
106
|
|
|
107
107
|
|
|
108
|
+
def test_chart_strain_not_in_tree_succeeds_with_prune_chart_to_tree() -> None:
|
|
109
|
+
"""`prune_chart_to_tree=True` filters chart rows whose strain isn't a
|
|
110
|
+
tree tip. The resulting chart's strain-axis sort matches the kept tree
|
|
111
|
+
tip order, and the dropped strain is gone from the chart's data."""
|
|
112
|
+
chart = _chart_for_strains(["A1", "A2", "A3", "B1", "B2", "X"])
|
|
113
|
+
out = tree_annotated_plot.plot(
|
|
114
|
+
_auspice_two_clades(),
|
|
115
|
+
chart,
|
|
116
|
+
chart_strain_field="strain",
|
|
117
|
+
tree_strain_field="name",
|
|
118
|
+
branch_length="div",
|
|
119
|
+
prune_chart_to_tree=True,
|
|
120
|
+
)
|
|
121
|
+
assert isinstance(out, alt.HConcatChart)
|
|
122
|
+
spec = out.to_dict()
|
|
123
|
+
# The strain-axis sort on the user-chart panel should be exactly the
|
|
124
|
+
# tree's tip order — no `X`.
|
|
125
|
+
sorts = []
|
|
126
|
+
for ch in out.hconcat:
|
|
127
|
+
ch_spec = ch.to_dict()
|
|
128
|
+
enc = ch_spec.get("encoding", {})
|
|
129
|
+
for channel in enc.values():
|
|
130
|
+
if isinstance(channel, dict) and channel.get("field") == "strain":
|
|
131
|
+
if isinstance(channel.get("sort"), list):
|
|
132
|
+
sorts.append(channel["sort"])
|
|
133
|
+
assert sorts, "expected at least one strain-axis encoding with a sort"
|
|
134
|
+
for s in sorts:
|
|
135
|
+
assert "X" not in s
|
|
136
|
+
assert set(s) <= {"A1", "A2", "A3", "B1", "B2"}
|
|
137
|
+
# And the user-chart data (now in `datasets`) should not contain X rows.
|
|
138
|
+
for rows in (spec.get("datasets") or {}).values():
|
|
139
|
+
if isinstance(rows, list) and rows and isinstance(rows[0], dict):
|
|
140
|
+
if "strain" in rows[0]:
|
|
141
|
+
assert all(r["strain"] != "X" for r in rows)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_prune_chart_to_tree_zero_overlap_raises() -> None:
|
|
145
|
+
"""If pruning would drop every chart row (no overlap with tree tips),
|
|
146
|
+
raise a clear error rather than producing an empty plot."""
|
|
147
|
+
chart = _chart_for_strains(["X", "Y", "Z"])
|
|
148
|
+
with pytest.raises(ValueError, match="dropped every chart row"):
|
|
149
|
+
tree_annotated_plot.plot(
|
|
150
|
+
_auspice_two_clades(),
|
|
151
|
+
chart,
|
|
152
|
+
chart_strain_field="strain",
|
|
153
|
+
tree_strain_field="name",
|
|
154
|
+
branch_length="div",
|
|
155
|
+
prune_chart_to_tree=True,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_prune_chart_and_tree_combined() -> None:
|
|
160
|
+
"""Both flags may be on at once: the chart has extras (X) and the tree
|
|
161
|
+
has tips the chart lacks (B1, B2). Pruning is bidirectional."""
|
|
162
|
+
chart = _chart_for_strains(["A1", "A2", "A3", "X"])
|
|
163
|
+
out = tree_annotated_plot.plot(
|
|
164
|
+
_auspice_two_clades(),
|
|
165
|
+
chart,
|
|
166
|
+
chart_strain_field="strain",
|
|
167
|
+
tree_strain_field="name",
|
|
168
|
+
branch_length="div",
|
|
169
|
+
prune_tree_to_chart=True,
|
|
170
|
+
prune_chart_to_tree=True,
|
|
171
|
+
)
|
|
172
|
+
assert isinstance(out, alt.HConcatChart)
|
|
173
|
+
# Final intersection is {A1, A2, A3}: chart loses X, tree loses B1/B2.
|
|
174
|
+
found_sort = None
|
|
175
|
+
for ch in out.hconcat:
|
|
176
|
+
enc = ch.to_dict().get("encoding", {})
|
|
177
|
+
for channel in enc.values():
|
|
178
|
+
if isinstance(channel, dict) and channel.get("field") == "strain":
|
|
179
|
+
if isinstance(channel.get("sort"), list):
|
|
180
|
+
found_sort = channel["sort"]
|
|
181
|
+
assert found_sort is not None
|
|
182
|
+
assert set(found_sort) == {"A1", "A2", "A3"}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_prune_chart_to_tree_default_error_mentions_flag() -> None:
|
|
186
|
+
"""When chart has strains not in tree and the user hasn't opted in,
|
|
187
|
+
the error message should suggest `prune_chart_to_tree=True`."""
|
|
188
|
+
chart = _chart_for_strains(["A1", "A2", "A3", "B1", "B2", "X"])
|
|
189
|
+
with pytest.raises(ValueError, match="prune_chart_to_tree=True"):
|
|
190
|
+
tree_annotated_plot.plot(
|
|
191
|
+
_auspice_two_clades(),
|
|
192
|
+
chart,
|
|
193
|
+
chart_strain_field="strain",
|
|
194
|
+
tree_strain_field="name",
|
|
195
|
+
branch_length="div",
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
108
199
|
# ---------- tree-not-in-chart (fatal unless prune) ----------
|
|
109
200
|
|
|
110
201
|
|
|
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
|