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.
Files changed (43) hide show
  1. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/CHANGELOG.md +19 -0
  2. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/PKG-INFO +5 -3
  3. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/README.md +1 -1
  4. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/docs/index.md +2 -2
  5. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/pyproject.toml +6 -4
  6. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/src/tree_annotated_plot/_config.py +8 -3
  7. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/src/tree_annotated_plot/_plot.py +101 -6
  8. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/src/tree_annotated_plot/cli.py +1 -0
  9. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/tests/test_cli.py +8 -0
  10. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/tests/test_reconciliation.py +94 -3
  11. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/.github/workflows/docs.yml +0 -0
  12. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/.github/workflows/release.yml +0 -0
  13. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/.github/workflows/tests.yml +0 -0
  14. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/.gitignore +0 -0
  15. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/CLAUDE.md +0 -0
  16. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/LICENSE +0 -0
  17. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/docs/cli.md +0 -0
  18. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/docs/examples.md +0 -0
  19. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/docs/python-api.md +0 -0
  20. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/examples/fetch_auspice_data.py +0 -0
  21. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/examples/flu-seqneut-2025to2026_titer_charts.py +0 -0
  22. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/examples/synthetic_example.py +0 -0
  23. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/mkdocs.yml +0 -0
  24. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/scripts/build_docs.sh +0 -0
  25. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/scripts/check.sh +0 -0
  26. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/scripts/generate_docs_assets.py +0 -0
  27. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/src/tree_annotated_plot/__init__.py +0 -0
  28. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/src/tree_annotated_plot/_color.py +0 -0
  29. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/src/tree_annotated_plot/_tree.py +0 -0
  30. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/tests/test_appearance_tuning.py +0 -0
  31. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/tests/test_branch_length.py +0 -0
  32. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/tests/test_chart_loader.py +0 -0
  33. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/tests/test_color_tree.py +0 -0
  34. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/tests/test_examples_run.py +0 -0
  35. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/tests/test_introspection.py +0 -0
  36. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/tests/test_label_connection.py +0 -0
  37. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/tests/test_line_order.py +0 -0
  38. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/tests/test_plot.py +0 -0
  39. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/tests/test_plot_config.py +0 -0
  40. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/tests/test_real_data.py +0 -0
  41. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/tests/test_scale_bar.py +0 -0
  42. {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.3.0}/tests/test_style.py +0 -0
  43. {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.2.2
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.13
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.13+.
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.13+.
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.13+.
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.1
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.2.2"
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.13"
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 = ["py313"]
57
+ target-version = ["py311"]
56
58
 
57
59
  [tool.ruff]
58
- target-version = "py313"
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
- "Chart strains not present in the tree are *always* fatal "
114
- "regardless of this flag — pruning would silently lose plot data.",
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 → always fatal.
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
- if not chart_minus_tree and (not tree_minus_chart or prune_tree_to_chart):
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 (these would be silently dropped if we pruned, so this is "
772
- "always fatal)."
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 (always fatal) ----------
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 test_chart_strain_not_in_tree_is_fatal_even_with_prune() -> None:
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 — pruning would silently lose plot data."""
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