diff --git a/altair/expr/core.py b/altair/expr/core.py index d894555b79..9a3506c298 100644 --- a/altair/expr/core.py +++ b/altair/expr/core.py @@ -283,7 +283,7 @@ def __init__(self, group, name) -> None: super().__init__(group=group, name=name) def __repr__(self) -> str: - return f"{self.group}[{self.name!r}]" + return f"{self.group}[{_js_repr(self.name)}]" IntoExpression: TypeAlias = Union[ diff --git a/altair/utils/core.py b/altair/utils/core.py index fe2b712091..a7cb17eef5 100644 --- a/altair/utils/core.py +++ b/altair/utils/core.py @@ -895,8 +895,9 @@ def get_encoding(self, tp: type[Any], /) -> str: msg = f"positional of type {type(tp).__name__!r}" raise NotImplementedError(msg) - def _wrap_in_channel(self, obj: Any, encoding: str, /): - from altair.expr.core import Expression + def _wrap_in_channel(self, obj: Any, encoding: str, /): # noqa: C901 + from altair.expr.core import Expression, GetItemExpression + from altair.vegalite.v6.api import Parameter from altair.vegalite.v6.schema.core import ExprRef if isinstance(obj, (Expression, ExprRef)): @@ -907,6 +908,15 @@ def _wrap_in_channel(self, obj: Any, encoding: str, /): return tp(shorthand=obj) return obj + # A VariableParameter used directly as an encoding value is sugar for + # alt.datum[param], which generates a transform_calculate: datum[param_name]. + if isinstance(obj, Parameter) and obj.param_type == "variable": + expr = GetItemExpression("datum", obj) + if channel := self.name_to_channel.get(encoding): + tp = channel["field"] + return tp(shorthand=expr) + return obj + if isinstance(obj, SchemaBase): return obj elif isinstance(obj, str): diff --git a/altair/vegalite/v6/schema/channels.py b/altair/vegalite/v6/schema/channels.py index 85ac96cfe7..271396d399 100644 --- a/altair/vegalite/v6/schema/channels.py +++ b/altair/vegalite/v6/schema/channels.py @@ -16,6 +16,7 @@ import narwhals.stable.v1 as nw from altair.expr.core import Expression as _Expression +from altair.expr.core import GetItemExpression from altair.utils import infer_encoding_types as _infer_encoding_types from altair.utils import parse_shorthand from altair.utils.schemapi import Undefined, _infer_expr_type, with_property_setters @@ -186,7 +187,15 @@ def to_dict( # noqa: C901 for sh in shorthand ] - shorthand_or_kwds = shorthand + from altair.vegalite.v6.api import Parameter + + def _param_to_expr(val: Any) -> Any: + # Convert a VariableParameter to a datum[param] expression. + if isinstance(val, Parameter) and val.param_type == "variable": + return GetItemExpression("datum", val) + return val + + shorthand_or_kwds = _param_to_expr(shorthand) if isinstance(shorthand_or_kwds, (_Expression, core.ExprRef)): vega_expr = ( diff --git a/doc/user_guide/api.rst b/doc/user_guide/api.rst index bec5713982..ec15535d24 100644 --- a/doc/user_guide/api.rst +++ b/doc/user_guide/api.rst @@ -55,6 +55,7 @@ Encoding Channels FillOpacityDatum FillOpacityValue FillValue + GetItemExpression Href HrefValue Key diff --git a/tests/expr/test_expr.py b/tests/expr/test_expr.py index addc29477e..6565daf897 100644 --- a/tests/expr/test_expr.py +++ b/tests/expr/test_expr.py @@ -10,6 +10,7 @@ import pytest from jsonschema.exceptions import ValidationError +import altair as alt from altair import datum, expr, ExprRef from altair.expr import _ExprMeta from altair.expr.core import Expression, GetAttrExpression @@ -167,6 +168,12 @@ def test_expression_getitem(): assert repr(x) == "datum.foo[0]" +def test_datum_getitem_param(): + xcol = alt.param(name="xcol", value="Horsepower") + x = datum[xcol] + assert repr(x) == "datum[xcol]" + + def test_expression_function_expr(): # test including an expr. should return an ExprRef er = expr(expr.PI * 2) diff --git a/tests/vegalite/v6/test_api.py b/tests/vegalite/v6/test_api.py index 5b9fc2b065..09bbd41c14 100644 --- a/tests/vegalite/v6/test_api.py +++ b/tests/vegalite/v6/test_api.py @@ -2240,6 +2240,37 @@ def test_inline_calc_deduplication(): assert spec["encoding"]["y"]["field"] == field_name +def test_inline_calc_variable_parameter_as_shorthand(): + """A VariableParameter used as encoding value expands to datum[param_name].""" + param = alt.param(name="xcol", value="x") + chart = ( + alt.Chart({"values": [{"x": 1}]}) + .mark_point() + .encode(x=alt.X(param, type="quantitative")) + .add_params(param) + ) + spec = chart.to_dict() + transforms = spec.get("transform", []) + assert len(transforms) == 1 + assert transforms[0]["calculate"] == "datum[xcol]" + field_name = transforms[0]["as"] + assert spec["encoding"]["x"]["field"] == field_name + + +def test_inline_calc_variable_parameter_bare_channel_value(): + """A VariableParameter used directly as x=param also expands to datum[param_name].""" + param = alt.param(name="xcol", value="x") + chart = ( + alt.Chart({"values": [{"x": 1}]}).mark_point().encode(x=param).add_params(param) + ) + spec = chart.to_dict() + transforms = spec.get("transform", []) + assert len(transforms) == 1 + assert transforms[0]["calculate"] == "datum[xcol]" + field_name = transforms[0]["as"] + assert spec["encoding"]["x"]["field"] == field_name + + def test_inline_calc_explicit_type_overrides_inferred(): """An explicit type on the channel overrides type inference.""" expr = alt.datum.x + alt.datum.y # would infer "quantitative" @@ -2273,6 +2304,19 @@ def test_inline_calc_exprref_string_syntax(): field_name = transforms[0]["as"] assert spec["encoding"]["x"]["field"] == field_name assert spec["encoding"]["x"]["type"] == "quantitative" + + +def test_inline_calc_title_none_respected_for_variable_parameter(): + """Explicit title(None) should be preserved for inline variable-parameter channels.""" + xcol_param = alt.param(name="xcol", value="x") + chart = ( + alt.Chart(pd.DataFrame({"x": [1, 2, 3]})) + .mark_point() + .encode(x=alt.X(xcol_param).type("quantitative").title(None)) + .add_params(xcol_param) + ) + + spec = chart.to_dict() assert spec["encoding"]["x"]["title"] is None @@ -2316,7 +2360,7 @@ def test_inline_calc_datum_expr_with_explicit_type_serializes_standard_type(): def test_inline_calc_does_not_rewrite_datum_expression_channels(): - """datum=ExprRef channels should stay datum-based (not auto-calc field rewrites).""" + """Datum expression channels should stay datum-based (not auto-calc rewrites).""" chart = ( alt.Chart() .mark_rule() @@ -2330,15 +2374,15 @@ def test_inline_calc_does_not_rewrite_datum_expression_channels(): spec = chart.to_dict() assert spec["encoding"]["x"] == { - "datum": {"expr": "domain('x')[0]"}, + "datum": {"expr": "domain('x',null)[0]"}, "type": "quantitative", } assert spec["encoding"]["y"] == { - "datum": {"expr": "domain('x')[0]"}, + "datum": {"expr": "domain('x',null)[0]"}, "type": "quantitative", } - assert spec["encoding"]["x2"] == {"datum": {"expr": "domain('x')[1]"}} - assert spec["encoding"]["y2"] == {"datum": {"expr": "domain('x')[1]"}} + assert spec["encoding"]["x2"] == {"datum": {"expr": "domain('x',null)[1]"}} + assert spec["encoding"]["y2"] == {"datum": {"expr": "domain('x',null)[1]"}} def test_datum_channel_accepts_expression_objects_without_alt_expr_wrapper(): diff --git a/tools/generate_schema_wrapper.py b/tools/generate_schema_wrapper.py index 1b241d2c7e..0b7c25e672 100644 --- a/tools/generate_schema_wrapper.py +++ b/tools/generate_schema_wrapper.py @@ -132,7 +132,15 @@ def to_dict( # noqa: C901 for sh in shorthand ] - shorthand_or_kwds = shorthand + from altair.vegalite.v6.api import Parameter + + def _param_to_expr(val: Any) -> Any: + # Convert a VariableParameter to a datum[param] expression. + if isinstance(val, Parameter) and val.param_type == "variable": + return GetItemExpression("datum", val) + return val + + shorthand_or_kwds = _param_to_expr(shorthand) if isinstance(shorthand_or_kwds, (_Expression, core.ExprRef)): vega_expr = ( @@ -920,7 +928,7 @@ def generate_vegalite_channel_wrappers(fp: Path, /) -> ModuleDef[list[str]]: "import sys", "from typing import Any, overload, Literal, Union, TypedDict", "import narwhals.stable.v1 as nw", - "from altair.expr.core import Expression as _Expression", + "from altair.expr.core import Expression as _Expression, GetItemExpression", "from altair.utils import infer_encoding_types as _infer_encoding_types", "from altair.utils import parse_shorthand", "from altair.utils.schemapi import Undefined, _infer_expr_type, with_property_setters",