feat: Support inline calculation transforms - #3991
Conversation
altair/utils/core.py (_wrap_in_channel):
- Removed try/except ImportError around from altair.vegalite.v6.api import Parameter — it's a same-package import that can never fail
- Collapsed the two separate Expression and Parameter branches into clean sequential checks
altair/vegalite/v6/schema/channels.py (FieldChannelMixin.to_dict):
- Added import hashlib at module level (was a local import inside the method)
- Added from altair.expr.core import Expression, GetItemExpression at module level
- Removed from altair.utils.schemapi import Undefined as AltairUndefined alias — uses Undefined directly
- Removed try/except ImportError around Parameter import (kept as function-local to avoid circular import, but without the try/except)
- Removed dead elif isinstance(val, Expression) branch (unreachable — _param_to_expr already converts expressions)
- Removed redundant else: shorthand_or_kwds = shorthand branch
- Changed auto_calc_transforms from a list to a dict[str, dict] keyed by field name → deduplicates identical expressions
- Added context.pop("parsed_shorthand", None) before early return → fixes a bug where the leftover parsed_shorthand in context was leaking into subsequent SchemaBase.to_dict() calls (manifested as a spurious field key appearing in serialized params)
altair/vegalite/v6/api.py (TopLevelMixin.to_dict):
- Restored pre_transform=False in the sub-call context (regression from previous session)
- Pre-initializes auto_calc_transforms as a shared dict object on context before creating the dict(context, pre_transform=False) copy — this ensures children write into the same shared dict object that the top-level code reads back
- Changed .extend(auto_calc_transforms) → .extend(auto_calc_transforms.values()) to match the new dict type
What I corrected/simplified:
- In FieldChannelMixin.to_dict (altair/vegalite/v6/schema/channels.py:163), I moved expression/variable-param handling before parsed-shorthand context setup.
- This removes the need for the previous context.pop("parsed_shorthand", None) workaround and avoids context leakage by design.
- Behavior is unchanged for normal field-string paths, but this is cleaner and less fragile.
What I added to tests:
- Added a missing regression test for the bare channel case:
- encode(x=param) (not just encode(x=alt.X(param, ...)))
- File: tests/vegalite/v6/test_api.py:2259
- This ensures _wrap_in_channel path for variable params is covered.
Limit inline transform_calculate generation to expression-valued encodings and remove variable-parameter sugar from this branch. This keeps expression support (e.g. x=alt.expr.random()) while deferring bare-parameter behavior (e.g. x=param) to a dedicated follow-up branch, with tests updated accordingly.
7a9f25a to
9d4f57f
Compare
| for sh in shorthand | ||
| ] | ||
|
|
||
| shorthand_or_kwds = shorthand |
There was a problem hiding this comment.
We intentionally inspect raw channel kwargs for Expression values before shorthand parsing so expression encodings can bypass normal field-shorthand parsing.
|
|
||
| if isinstance(shorthand_or_kwds, Expression): | ||
| vega_expr = repr(shorthand_or_kwds) | ||
| field_hash = hashlib.md5(vega_expr.encode()).hexdigest()[:8] |
There was a problem hiding this comment.
Calculated field names are deterministic (md5(repr(expr))[:8]) so repeated expressions across channels reuse the same transform.
| vega_expr = repr(shorthand_or_kwds) | ||
| field_hash = hashlib.md5(vega_expr.encode()).hexdigest()[:8] | ||
| calc_field_name = f"_calc_{field_hash}" | ||
| transforms: dict[str, dict] = context.setdefault("auto_calc_transforms", {}) |
There was a problem hiding this comment.
auto_calc_transforms is a dict (not list) to deduplicate transforms by generated field name.
| ) | ||
|
|
||
| result: dict[str, Any] = {"field": calc_field_name} | ||
| explicit_type = self._get("type") # type: ignore[attr-defined] |
There was a problem hiding this comment.
If channel type is explicitly provided, it wins over inferred type; inference is best-effort fallback.
| elif inferred := _infer_expr_type(shorthand_or_kwds): | ||
| result["type"] = inferred | ||
| return result | ||
|
|
There was a problem hiding this comment.
Expression path returns early above; normal shorthand parsing continues unchanged for non-expression encodings below.
|
|
||
| # remaining to_dict calls are not at top level | ||
| context["top_level"] = False | ||
| # pre-initialize so children can write into it by reference, |
There was a problem hiding this comment.
Pre-initialize shared transform accumulator so child serialization can mutate the same object even when receiving dict(context, pre_transform=False).
| ) | ||
|
|
||
| context.pop("auto_calc_transforms", None) | ||
| if auto_calc_transforms and is_top_level: |
There was a problem hiding this comment.
Only top-level charts inject accumulated auto-calc transforms into final spec
| return result | ||
|
|
||
|
|
||
| def _infer_expr_type(expr: Any) -> str | None: # noqa: C901 |
There was a problem hiding this comment.
Type inference is intentionally heuristic and conservative; unknown/ambiguous expressions return None to avoid incorrect schema typing. It's also possible to override manually by using alt.X(expr).type(...)
| it = chain.from_iterable(info.all_names for info in channel_infos.values()) | ||
| all_ = sorted(chain(it, COMPAT_EXPORTS)) | ||
| imports = [ | ||
| "import hashlib", |
There was a problem hiding this comment.
These imports must include Expression, hashlib, and _infer_expr_type because generated channels.py depends on them.
The autogenerated field names are not useful as axis titles, so instead of always having to change them, they are now set to empty/None. If vega-lite supports params for axis titles in the future we could maybe do something smarter
Avoid that it is picked up as a public API symbol, which causes docs to fail: - Expression was imported into altair.vegalite.v6.schema.channels at module scope. - API docs generation scans public names in that module and incorrectly added Expression to autosummary. - Sphinx then tried to import altair.Expression, which does not exist.
Trying to reference it from multiple places so that it is more discoverable
alt.datum(alt.expr...) should not be converted into transform as that breaks some old behavior like the identiy line example
| alt.Chart() | ||
| .mark_rule() | ||
| .encode( | ||
| x=alt.datum(alt.expr("domain('x')[0]"), type="quantitative"), |
There was a problem hiding this comment.
Just reading the diff here, will alt.expr.domain('x')[0] also work here?
There was a problem hiding this comment.
Yup, I actually just fixed that in 2a87ca1 and will update this example to use the simpler syntax. I changed it so both 'domain' and alt.expr.domain will generate an ExprRef when wrapped in alt.datum() (rather than generated in Expression or being converted into calculate transforms)
Altair already supports inline aggregation transforms via magic string syntax such as
'mean(field_name)'. This PR adds support for inline calculation transforms by passing expressions to encoding fields. The corresponding calculation transform is automatically generated and a hash-based field name is used, since the idea is that these calculations are just used for a single field. This only works with thealt.exprmodule and not with js expression strings.Expression syntax now supported on this branch:
encode(x=alt.expr.random())encode(x=alt.datum.a + alt.datum.b)encode(x=alt.X(alt.datum.a + 1).type("quantitative"))encode(alt.expr('random()')for consistency with how these are used interchangeably elsewhere, but I would be ok to drop this in favor of always using theexprsubmodule.There are other options for the syntax, so let's discuss in #3992 before considering merging this PR.
Closes #3992 and #3602.
Examples
Details