tree-annotated-plot 0.2.0__tar.gz → 0.2.1__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.0 → tree_annotated_plot-0.2.1}/CHANGELOG.md +13 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/PKG-INFO +1 -1
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/pyproject.toml +1 -1
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/src/tree_annotated_plot/_plot.py +119 -67
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/tests/test_introspection.py +68 -1
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/tests/test_plot.py +67 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/.github/workflows/docs.yml +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/.github/workflows/release.yml +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/.github/workflows/tests.yml +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/.gitignore +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/CLAUDE.md +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/LICENSE +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/README.md +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/docs/cli.md +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/docs/examples.md +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/docs/index.md +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/docs/python-api.md +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/examples/fetch_auspice_data.py +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/examples/flu-seqneut-2025to2026_titer_charts.py +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/examples/synthetic_example.py +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/mkdocs.yml +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/scripts/build_docs.sh +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/scripts/check.sh +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/scripts/generate_docs_assets.py +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/src/tree_annotated_plot/__init__.py +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/src/tree_annotated_plot/_color.py +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/src/tree_annotated_plot/_config.py +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/src/tree_annotated_plot/_tree.py +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/src/tree_annotated_plot/cli.py +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/tests/test_appearance_tuning.py +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/tests/test_branch_length.py +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/tests/test_chart_loader.py +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/tests/test_cli.py +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/tests/test_color_tree.py +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/tests/test_examples_run.py +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/tests/test_label_connection.py +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/tests/test_plot_config.py +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/tests/test_real_data.py +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/tests/test_reconciliation.py +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/tests/test_scale_bar.py +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/tests/test_style.py +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/tests/test_tree_location.py +0 -0
|
@@ -5,6 +5,19 @@ 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
|
+
## [0.2.1] - 2026-05-06
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- Apply tree-tip ordering to chart axes that use untyped Altair
|
|
13
|
+
shorthand (`alt.Y("strain")` without a `:N` / `:O` type suffix).
|
|
14
|
+
Previously the sort override was silently skipped, so the chart
|
|
15
|
+
rendered in data order instead of tree order.
|
|
16
|
+
- An internal consistency check now raises if the spec-level walk
|
|
17
|
+
and the live-object walk over the user's chart ever disagree on
|
|
18
|
+
the number of strain-axis encodings, so silent skips like the
|
|
19
|
+
one above can't recur in another shape.
|
|
20
|
+
|
|
8
21
|
## [0.2.0] - 2026-05-06
|
|
9
22
|
|
|
10
23
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tree-annotated-plot
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
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
|
|
@@ -7,6 +7,7 @@ import json
|
|
|
7
7
|
import math
|
|
8
8
|
import re
|
|
9
9
|
import warnings
|
|
10
|
+
from collections.abc import Iterator
|
|
10
11
|
from html.parser import HTMLParser
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
from typing import Any, Literal
|
|
@@ -223,11 +224,15 @@ def _build(
|
|
|
223
224
|
legend_show=config.tree_color_legend_show,
|
|
224
225
|
)
|
|
225
226
|
|
|
226
|
-
new_chart =
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
227
|
+
new_chart = copy.deepcopy(chart)
|
|
228
|
+
suppress_axis_chrome = config.connect_leader_to_label
|
|
229
|
+
n_hits = 0
|
|
230
|
+
for ch in _iter_strain_axis_channels(new_chart, config.chart_strain_field):
|
|
231
|
+
ch.sort = list(tip_names)
|
|
232
|
+
if suppress_axis_chrome:
|
|
233
|
+
ch.axis = alt.Axis(labels=False, ticks=False, domain=False, title=None)
|
|
234
|
+
n_hits += 1
|
|
235
|
+
_check_walker_hits("strain-axis update", n_hits, len(axis_hits), axis)
|
|
231
236
|
hoisted_config, hoisted_other = _pop_toplevel_only_attrs(new_chart)
|
|
232
237
|
|
|
233
238
|
combined = _concat_for_location(
|
|
@@ -916,11 +921,45 @@ def _enumerate_tree_tip_attrs(
|
|
|
916
921
|
return out
|
|
917
922
|
|
|
918
923
|
|
|
919
|
-
def
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
+
def _check_walker_hits(operation: str, actual: int, expected: int, axis: str) -> None:
|
|
925
|
+
"""Cross-check the live-object walk's hit count against the spec walk's.
|
|
926
|
+
|
|
927
|
+
`_find_strain_encoding` walks the chart's serialized dict form to count
|
|
928
|
+
how many `x`/`y` strain encodings the chart has and to validate
|
|
929
|
+
consistency (axis agreement, type=nominal/ordinal, etc.). The live
|
|
930
|
+
iteration in `_build` (driven by `_iter_strain_axis_channels`) must
|
|
931
|
+
visit exactly the same number of encodings — fewer would silently skip
|
|
932
|
+
applying the tree's tip order (or the axis suppression) to part of the
|
|
933
|
+
chart, more would mean we mutated structures the dict walker doesn't
|
|
934
|
+
know about.
|
|
935
|
+
|
|
936
|
+
The check is symmetric (`!=`) rather than one-sided (`<`) because
|
|
937
|
+
either direction signals that spec-level introspection and
|
|
938
|
+
live-object traversal have diverged, and continuing would render an
|
|
939
|
+
unverified chart.
|
|
940
|
+
"""
|
|
941
|
+
if actual != expected:
|
|
942
|
+
raise RuntimeError(
|
|
943
|
+
f"internal consistency check failed for {operation!r}: "
|
|
944
|
+
f"_find_strain_encoding located {expected} strain {axis!r}-axis "
|
|
945
|
+
f"encoding(s) in the chart spec, but the live-object walk "
|
|
946
|
+
f"updated {actual}. Spec-level and live-object traversal have "
|
|
947
|
+
"diverged, which would render the chart with a wrong tip order "
|
|
948
|
+
"or leave axis chrome behind. Please file a bug at "
|
|
949
|
+
"https://github.com/jbloomlab/tree-annotated-plot/issues with a "
|
|
950
|
+
"minimal reproducer."
|
|
951
|
+
)
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def _iter_strain_axis_channels(node: Any, chart_strain_field: str) -> Iterator[Any]:
|
|
955
|
+
"""Yield every live x/y channel object whose field matches
|
|
956
|
+
`chart_strain_field`.
|
|
957
|
+
|
|
958
|
+
Pure read — no mutation, no count. The caller iterates and applies
|
|
959
|
+
whatever mutation it needs (currently `sort` and, when
|
|
960
|
+
`connect_leader_to_label=True`, an axis-suppression `alt.Axis(...)`),
|
|
961
|
+
counting hits as it goes so the cross-check in `_check_walker_hits`
|
|
962
|
+
can compare against the spec walker.
|
|
924
963
|
|
|
925
964
|
Walks the live altair object tree (Chart / LayerChart / FacetChart /
|
|
926
965
|
HConcatChart / VConcatChart / ConcatChart) rather than its dict form,
|
|
@@ -929,62 +968,25 @@ def _apply_tree_order_to_chart_object(
|
|
|
929
968
|
dict approach has to fight. Modifying the object in place is robust as
|
|
930
969
|
long as altair's container attribute names hold (.hconcat / .vconcat /
|
|
931
970
|
.concat / .layer / .spec / .encoding), which is stable in altair 5+.
|
|
971
|
+
`FacetChart.spec` is recursed into unconditionally so we descend to the
|
|
972
|
+
inner LayerChart / Chart that actually carries the encoding (gating on
|
|
973
|
+
`spec.encoding is not None` would skip LayerCharts, whose encodings
|
|
974
|
+
live on their layers rather than at the top level).
|
|
932
975
|
"""
|
|
933
|
-
new_chart = copy.deepcopy(chart)
|
|
934
|
-
_walk_and_apply_sort(new_chart, chart_strain_field, sort_order)
|
|
935
|
-
return new_chart
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
def _walk_and_apply_sort(
|
|
939
|
-
node: Any, chart_strain_field: str, sort_order: list[str]
|
|
940
|
-
) -> None:
|
|
941
|
-
"""Recursively set sort on every encoding whose field == chart_strain_field
|
|
942
|
-
on a live altair chart object."""
|
|
943
976
|
enc = _live_attr(node, "encoding")
|
|
944
977
|
if enc is not None:
|
|
945
978
|
for channel in ("x", "y"):
|
|
946
979
|
ch = _live_attr(enc, channel)
|
|
947
980
|
if ch is not None and _channel_field(ch) == chart_strain_field:
|
|
948
|
-
ch
|
|
981
|
+
yield ch
|
|
949
982
|
for attr in ("hconcat", "vconcat", "concat", "layer"):
|
|
950
983
|
sub = _live_attr(node, attr)
|
|
951
984
|
if isinstance(sub, list):
|
|
952
985
|
for s in sub:
|
|
953
|
-
|
|
954
|
-
# FacetChart.spec is the chart being faceted; recurse unconditionally so
|
|
955
|
-
# we descend into the inner LayerChart / Chart that actually carries the
|
|
956
|
-
# encoding. Gating on `spec.encoding is not None` was wrong because a
|
|
957
|
-
# LayerChart has no top-level encoding — its encodings live on its layers.
|
|
986
|
+
yield from _iter_strain_axis_channels(s, chart_strain_field)
|
|
958
987
|
spec = _live_attr(node, "spec")
|
|
959
988
|
if spec is not None:
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
def _suppress_chart_strain_axis(
|
|
964
|
-
chart: alt.TopLevelMixin, chart_strain_field: str
|
|
965
|
-
) -> None:
|
|
966
|
-
"""Hide labels, ticks, axis line, and title on every chart strain-axis encoding.
|
|
967
|
-
|
|
968
|
-
Walks the live altair object the same way `_walk_and_apply_sort` does
|
|
969
|
-
and replaces the matching encoding's ``axis`` with one that suppresses
|
|
970
|
-
every visible bit of axis chrome. This **overrides** any user-supplied
|
|
971
|
-
``axis=alt.Axis(...)`` on those encodings; that's documented on the
|
|
972
|
-
``connect_leader_to_label`` description in `_config.py`.
|
|
973
|
-
"""
|
|
974
|
-
enc = _live_attr(chart, "encoding")
|
|
975
|
-
if enc is not None:
|
|
976
|
-
for channel in ("x", "y"):
|
|
977
|
-
ch = _live_attr(enc, channel)
|
|
978
|
-
if ch is not None and _channel_field(ch) == chart_strain_field:
|
|
979
|
-
ch.axis = alt.Axis(labels=False, ticks=False, domain=False, title=None)
|
|
980
|
-
for attr in ("hconcat", "vconcat", "concat", "layer"):
|
|
981
|
-
sub = _live_attr(chart, attr)
|
|
982
|
-
if isinstance(sub, list):
|
|
983
|
-
for s in sub:
|
|
984
|
-
_suppress_chart_strain_axis(s, chart_strain_field)
|
|
985
|
-
spec = _live_attr(chart, "spec")
|
|
986
|
-
if spec is not None:
|
|
987
|
-
_suppress_chart_strain_axis(spec, chart_strain_field)
|
|
989
|
+
yield from _iter_strain_axis_channels(spec, chart_strain_field)
|
|
988
990
|
|
|
989
991
|
|
|
990
992
|
def _live_attr(obj: Any, name: str) -> Any:
|
|
@@ -1044,21 +1046,71 @@ def _apply_combined_config(combined: alt.HConcatChart, hoisted_config: Any) -> N
|
|
|
1044
1046
|
def _channel_field(ch: Any) -> str | None:
|
|
1045
1047
|
"""Read the underlying `field` string from an altair channel object.
|
|
1046
1048
|
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
+
Returns the field name when the channel references a data field (either
|
|
1050
|
+
via the `field=` keyword or via positional shorthand like
|
|
1051
|
+
`alt.Y("strain")` / `alt.Y("strain:N")` / `alt.Y("mean(titer):Q")`).
|
|
1052
|
+
Returns `None` when the channel has no field at all (a `value=` /
|
|
1053
|
+
`datum=` constant encoding). Raises `ValueError` when the channel's
|
|
1054
|
+
`_kwds` shape is unrecognized — silent fallthrough hid a real bug
|
|
1055
|
+
where untyped shorthand axes were never reordered to match the tree.
|
|
1056
|
+
|
|
1057
|
+
Reads `ch._kwds` directly rather than going through `ch.to_dict()`:
|
|
1058
|
+
altair's `to_dict()` on a bare channel raises when the shorthand has
|
|
1059
|
+
no explicit type (e.g. `alt.Y("strain")`), because the `nominal` /
|
|
1060
|
+
`quantitative` inference needs the chart's data context. That
|
|
1061
|
+
exception is what the previous catch-all hid.
|
|
1049
1062
|
"""
|
|
1050
|
-
|
|
1051
|
-
if not
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1063
|
+
kwds = getattr(ch, "_kwds", None)
|
|
1064
|
+
if not isinstance(kwds, dict):
|
|
1065
|
+
raise ValueError(
|
|
1066
|
+
f"channel object {type(ch).__name__} has no `_kwds` mapping; "
|
|
1067
|
+
"this isn't a recognized altair channel encoding."
|
|
1068
|
+
)
|
|
1069
|
+
field = kwds.get("field")
|
|
1070
|
+
if field is not None and field is not alt.Undefined:
|
|
1071
|
+
# `from_dict`-roundtripped channels store the field as a
|
|
1072
|
+
# `FieldName(SchemaBase)` wrapper rather than a plain str; its
|
|
1073
|
+
# `to_dict()` returns the raw string, while `str(...)` returns the
|
|
1074
|
+
# repr `FieldName('x')`. Cover both.
|
|
1075
|
+
if isinstance(field, str):
|
|
1076
|
+
if field:
|
|
1077
|
+
return field
|
|
1078
|
+
elif hasattr(field, "to_dict"):
|
|
1079
|
+
unwrapped = field.to_dict()
|
|
1080
|
+
if isinstance(unwrapped, str) and unwrapped:
|
|
1081
|
+
return unwrapped
|
|
1082
|
+
raise ValueError(
|
|
1083
|
+
f"channel field wrapper {type(field).__name__} unwrapped "
|
|
1084
|
+
f"to {unwrapped!r}; expected a non-empty string."
|
|
1085
|
+
)
|
|
1086
|
+
else:
|
|
1087
|
+
raise ValueError(
|
|
1088
|
+
f"channel field has unexpected value {field!r} "
|
|
1089
|
+
f"(type {type(field).__name__}); expected a string."
|
|
1090
|
+
)
|
|
1091
|
+
shorthand = kwds.get("shorthand")
|
|
1092
|
+
if shorthand is None or shorthand is alt.Undefined:
|
|
1056
1093
|
return None
|
|
1057
|
-
if isinstance(
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1094
|
+
if not isinstance(shorthand, str) or not shorthand:
|
|
1095
|
+
raise ValueError(
|
|
1096
|
+
f"channel shorthand has unexpected value {shorthand!r}; "
|
|
1097
|
+
"expected a string like 'strain', 'strain:N', or "
|
|
1098
|
+
"'mean(strain):Q'."
|
|
1099
|
+
)
|
|
1100
|
+
# Shorthand grammar: '[<aggregate>(]<field>[)][:<type>]'.
|
|
1101
|
+
bare = shorthand.split(":", 1)[0]
|
|
1102
|
+
if "(" in bare:
|
|
1103
|
+
if not bare.endswith(")"):
|
|
1104
|
+
raise ValueError(
|
|
1105
|
+
f"channel shorthand {shorthand!r} has an unbalanced "
|
|
1106
|
+
"aggregate wrapper; expected 'aggregate(field)[:type]'."
|
|
1107
|
+
)
|
|
1108
|
+
bare = bare[bare.index("(") + 1 : -1]
|
|
1109
|
+
if not bare:
|
|
1110
|
+
raise ValueError(
|
|
1111
|
+
f"channel shorthand {shorthand!r} parsed to an empty field name."
|
|
1112
|
+
)
|
|
1113
|
+
return bare
|
|
1062
1114
|
|
|
1063
1115
|
|
|
1064
1116
|
def _chart_strain_dim(
|
|
@@ -15,7 +15,7 @@ import altair as alt
|
|
|
15
15
|
import pandas as pd
|
|
16
16
|
import pytest
|
|
17
17
|
|
|
18
|
-
from tree_annotated_plot._plot import _find_strain_encoding
|
|
18
|
+
from tree_annotated_plot._plot import _channel_field, _find_strain_encoding
|
|
19
19
|
|
|
20
20
|
DATA_DIR = Path(__file__).resolve().parent.parent / "examples" / "data"
|
|
21
21
|
|
|
@@ -139,3 +139,70 @@ def test_find_strain_encoding_quantitative_type_raises() -> None:
|
|
|
139
139
|
spec = _flat_chart_spec(typ="quantitative")
|
|
140
140
|
with pytest.raises(ValueError, match="type='quantitative'"):
|
|
141
141
|
_find_strain_encoding(spec, "strain")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------- _channel_field: covers the "untyped shorthand" silent-skip bug.
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_channel_field_typed_shorthand() -> None:
|
|
148
|
+
"""`alt.Y('strain:N')` — the form every existing test uses."""
|
|
149
|
+
ch = alt.Y("strain:N")
|
|
150
|
+
assert _channel_field(ch) == "strain"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_channel_field_untyped_shorthand() -> None:
|
|
154
|
+
"""`alt.Y('strain')` without a type — the form that triggered the
|
|
155
|
+
original silent-skip bug. `to_dict()` on this channel raises because
|
|
156
|
+
type inference needs the chart's data, but we can still recover the
|
|
157
|
+
field from `_kwds['shorthand']`."""
|
|
158
|
+
ch = alt.Y("strain")
|
|
159
|
+
assert _channel_field(ch) == "strain"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_channel_field_explicit_field_kwarg() -> None:
|
|
163
|
+
"""`alt.Y(field='strain', type='nominal')` — the explicit form."""
|
|
164
|
+
ch = alt.Y(field="strain", type="nominal")
|
|
165
|
+
assert _channel_field(ch) == "strain"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_channel_field_after_from_dict_roundtrip() -> None:
|
|
169
|
+
"""After `alt.Chart.from_dict(...)` (used by the CLI / JSON / HTML
|
|
170
|
+
chart loaders), the field is stored as a `FieldName(SchemaBase)`
|
|
171
|
+
wrapper, not a plain `str`. The helper must unwrap it."""
|
|
172
|
+
df = pd.DataFrame({"strain": ["A", "B"], "titer": [1.0, 2.0]})
|
|
173
|
+
chart = alt.Chart(df).mark_circle().encode(x="titer:Q", y=alt.Y("strain:N"))
|
|
174
|
+
roundtripped = alt.Chart.from_dict(chart.to_dict())
|
|
175
|
+
assert _channel_field(roundtripped.encoding.y) == "strain"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_channel_field_aggregate_shorthand() -> None:
|
|
179
|
+
"""`alt.X('mean(titer):Q')` aggregate-wrapped shorthand."""
|
|
180
|
+
ch = alt.X("mean(titer):Q")
|
|
181
|
+
assert _channel_field(ch) == "titer"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_channel_field_value_only_returns_none() -> None:
|
|
185
|
+
"""A `value=` constant encoding has no field — legitimate None,
|
|
186
|
+
not an error."""
|
|
187
|
+
ch = alt.Y(value=5)
|
|
188
|
+
assert _channel_field(ch) is None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_channel_field_unbalanced_aggregate_raises() -> None:
|
|
192
|
+
"""A shorthand string we can't parse must raise rather than silently
|
|
193
|
+
return None — silent fallthrough is what hid the original bug."""
|
|
194
|
+
ch = alt.Y("strain")
|
|
195
|
+
ch._kwds["shorthand"] = "mean(titer:Q"
|
|
196
|
+
with pytest.raises(ValueError, match="unbalanced aggregate"):
|
|
197
|
+
_channel_field(ch)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_channel_field_non_kwds_object_raises() -> None:
|
|
201
|
+
"""A non-altair object accidentally passed in must raise, not return
|
|
202
|
+
None — same fail-fast principle."""
|
|
203
|
+
|
|
204
|
+
class NotAChannel:
|
|
205
|
+
pass
|
|
206
|
+
|
|
207
|
+
with pytest.raises(ValueError, match="no `_kwds` mapping"):
|
|
208
|
+
_channel_field(NotAChannel())
|
|
@@ -110,6 +110,73 @@ def test_plot_overrides_y_sort_to_tree_tip_order():
|
|
|
110
110
|
assert user_panel_spec["encoding"]["y"]["sort"] == ["A", "B", "C", "D"]
|
|
111
111
|
|
|
112
112
|
|
|
113
|
+
def test_plot_overrides_sort_for_untyped_shorthand_y():
|
|
114
|
+
"""Regression: `alt.Y('strain')` (no `:N` suffix) used to silently
|
|
115
|
+
skip the sort override because `_channel_field` called `to_dict()`
|
|
116
|
+
on the bare channel, which raises without a chart-data context."""
|
|
117
|
+
chart = (
|
|
118
|
+
alt.Chart(_synthetic_df())
|
|
119
|
+
.mark_line(point=True)
|
|
120
|
+
.encode(
|
|
121
|
+
x=alt.X("titer:Q", scale=alt.Scale(type="log")),
|
|
122
|
+
y=alt.Y("strain"),
|
|
123
|
+
color="serum:N",
|
|
124
|
+
)
|
|
125
|
+
.properties(width=300, height=200)
|
|
126
|
+
)
|
|
127
|
+
out = tree_annotated_plot.plot(
|
|
128
|
+
_synthetic_auspice(),
|
|
129
|
+
chart,
|
|
130
|
+
chart_strain_field="strain",
|
|
131
|
+
tree_strain_field="name",
|
|
132
|
+
branch_length="div",
|
|
133
|
+
)
|
|
134
|
+
user_panel_spec = out.hconcat[1].to_dict()
|
|
135
|
+
assert user_panel_spec["encoding"]["y"]["sort"] == ["A", "B", "C", "D"]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_plot_overrides_sort_for_untyped_shorthand_x():
|
|
139
|
+
"""Same regression on the horizontal layout."""
|
|
140
|
+
chart = (
|
|
141
|
+
alt.Chart(_synthetic_df())
|
|
142
|
+
.mark_line(point=True)
|
|
143
|
+
.encode(
|
|
144
|
+
x=alt.X("strain"),
|
|
145
|
+
y=alt.Y("titer:Q", scale=alt.Scale(type="log")),
|
|
146
|
+
color="serum:N",
|
|
147
|
+
)
|
|
148
|
+
.properties(width=200, height=300)
|
|
149
|
+
)
|
|
150
|
+
out = tree_annotated_plot.plot(
|
|
151
|
+
_synthetic_auspice(),
|
|
152
|
+
chart,
|
|
153
|
+
chart_strain_field="strain",
|
|
154
|
+
tree_strain_field="name",
|
|
155
|
+
branch_length="div",
|
|
156
|
+
)
|
|
157
|
+
# tree_location defaults to "bottom" for x-axis strain → vconcat(user, tree)
|
|
158
|
+
user_panel_spec = out.vconcat[0].to_dict()
|
|
159
|
+
assert user_panel_spec["encoding"]["x"]["sort"] == ["A", "B", "C", "D"]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_plot_consistency_tripwire_fires_when_live_walker_misses(monkeypatch):
|
|
163
|
+
"""If `_channel_field` ever reverts to silently failing, the spec walker
|
|
164
|
+
finds a strain encoding the live walker doesn't, and the count-based
|
|
165
|
+
consistency check raises rather than letting a wrong-ordered chart
|
|
166
|
+
render."""
|
|
167
|
+
from tree_annotated_plot import _plot
|
|
168
|
+
|
|
169
|
+
monkeypatch.setattr(_plot, "_channel_field", lambda ch: None)
|
|
170
|
+
with pytest.raises(RuntimeError, match="internal consistency check failed"):
|
|
171
|
+
tree_annotated_plot.plot(
|
|
172
|
+
_synthetic_auspice(),
|
|
173
|
+
_synthetic_chart(),
|
|
174
|
+
chart_strain_field="strain",
|
|
175
|
+
tree_strain_field="name",
|
|
176
|
+
branch_length="div",
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
113
180
|
def test_plot_strain_mismatch_raises():
|
|
114
181
|
bad = _synthetic_chart()
|
|
115
182
|
bad.data = bad.data.replace({"strain": {"D": "X"}})
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|