-
Notifications
You must be signed in to change notification settings - Fork 795
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Improve (Expression
|expr
|Expr
|ExprRef
) UX & ergonomics
#3616
Comments
Thanks for opening this @mattijn The reason I wanted an issue was to investigate why the definitions marked as Code block from reviewimport altair as alt
from vega_datasets import data
source = data.barley()
base = alt.Chart(source).encode(
x=alt.X("sum(yield):Q").stack("zero"),
y=alt.Y("site:O").sort("-x"),
text=alt.Text("sum(yield):Q", format=".0f"),
)
# NOTE: Original `str` expressions
# tooltip = alt.expr("luminance(scale('color', datum.sum_yield))")
# color = alt.expr("luminance(scale('color', datum.sum_yield)) > 0.5 ? 'black' : 'white'")
luminance = alt.expr.luminance(alt.expr.scale("color", alt.datum.sum_yield))
# BUG: These definitions alone *should* work, but don't evaluate as expressions
tooltip = luminance
color = alt.expr.if_(luminance > 0.5, "black", "white")
# HACK: Actual additional steps required
tooltip = alt.expr(repr(luminance))
color = alt.expr(repr(color))
bars = base.mark_bar(tooltip=tooltip).encode(color="sum(yield):Q")
text = base.mark_text(align="right", dx=-3, color=color)
bars + text I'm now seeing that this is the distinction between I think handling this implicitly could be error prone. A simple alternative could be adding Diff
|
We have had |
Do you mean altair/altair/vegalite/v5/api.py Lines 355 to 389 in cabf1e6
My suggestion was for |
Ah yeah, that was for |
@mattijn Well that part would be solved by communicating this in annotations 😃 It would also be quite similar to the recent changes with conditions in #3567
|
class _ConditionExtra(TypedDict, closed=True, total=False): # type: ignore[call-arg] | |
# https://peps.python.org/pep-0728/ | |
# Likely a Field predicate | |
empty: Optional[bool] | |
param: Parameter | str | |
test: _TestPredicateType | |
value: Any | |
__extra_items__: _StatementType | OneOrSeq[_LiteralValue] | |
_Condition: TypeAlias = _ConditionExtra | |
""" | |
A singular, *possibly* non-chainable condition produced by ``.when()``. | |
The default **permissive** representation. | |
Allows arbitrary additional keys that *may* be present in a `Conditional Field`_ | |
but not a `Conditional Value`_. | |
.. _Conditional Field: | |
https://vega.github.io/vega-lite/docs/condition.html#field | |
.. _Conditional Value: | |
https://vega.github.io/vega-lite/docs/condition.html#value | |
""" | |
class _ConditionClosed(TypedDict, closed=True, total=False): # type: ignore[call-arg] | |
# https://peps.python.org/pep-0728/ | |
# Parameter {"param", "value", "empty"} | |
# Predicate {"test", "value"} | |
empty: Optional[bool] | |
param: Parameter | str | |
test: _TestPredicateType | |
value: Any | |
_Conditions: TypeAlias = t.List[_ConditionClosed] | |
""" | |
Chainable conditions produced by ``.when()`` and ``Then.when()``. | |
All must be a `Conditional Value`_. | |
.. _Conditional Value: | |
https://vega.github.io/vega-lite/docs/condition.html#value | |
""" | |
_C = TypeVar("_C", _Conditions, _Condition) | |
class _Conditional(TypedDict, t.Generic[_C], total=False): | |
""" | |
A dictionary representation of a conditional encoding or property. | |
Parameters | |
---------- | |
condition | |
One or more (predicate, statement) pairs which each form a condition. | |
value | |
An optional default value, used when no predicates were met. | |
""" | |
condition: Required[_C] | |
value: Any | |
IntoCondition: TypeAlias = Union[ConditionLike, _Conditional[Any]] | |
""" | |
Anything that can be converted into a conditional encoding or property. | |
Notes | |
----- | |
Represents all outputs from `when-then-otherwise` conditions, which are not ``SchemaBase`` types. | |
""" | |
class _Value(TypedDict, closed=True, total=False): # type: ignore[call-arg] | |
# https://peps.python.org/pep-0728/ | |
value: Required[Any] | |
__extra_items__: Any |
A lot of this is about identifying either a dict
with a key "condition"
or an object with an attribute object.condition
.
The same idea would apply for this case, but switching to "expr"
or object.expr
.
Since we know precisely what every schema will permit; I could look into how we can represent this scenario as part of SchemaInfo.to_type_repr()
?
altair/tools/schemapi/utils.py
Lines 455 to 479 in cabf1e6
def to_type_repr( # noqa: C901 | |
self, | |
*, | |
as_str: bool = True, | |
target: TargetType = "doc", | |
use_concrete: bool = False, | |
use_undefined: bool = False, | |
) -> str | list[str]: | |
""" | |
Return the python type representation of ``SchemaInfo``. | |
Includes `altair` classes, standard `python` types, etc. | |
Parameters | |
---------- | |
as_str | |
Return as a string. | |
Should only be ``False`` during internal recursive calls. | |
target: {"annotation", "doc"} | |
Where the representation will be used. | |
use_concrete | |
Avoid base classes/wrappers that don't provide type info. | |
use_undefined | |
Wrap the result in ``altair.typing.Optional``. | |
""" |
It would be quite an achievement if you manage, but you might be right that it is actually possible. |
@mattijn I'll keep this comment updated with all the context I can dig up. Related
|
tp_param: set[str] = {"ExprRef", "ParameterExtent"} | |
# In these cases, a `VariableParameter` is also always accepted. | |
# It could be difficult to differentiate `(Variable|Selection)Parameter`, with typing. | |
# TODO: A solution could be defining `Parameter` as generic over either `param` or `param_type`. | |
# - Rewriting the init logic to not use an `Undefined` default. | |
# - Any narrowing logic could be factored-out into `is_(selection|variable)_parameter` guards. | |
EXCLUDE_TITLE: set[str] = tp_param | {"RelativeBandSize"} | |
""" | |
`RelativeBandSize` excluded as it has a single property `band`, | |
but all instances also accept `float`. | |
""" | |
REMAP_TITLE = SchemaInfo._remap_title | |
title: str = self.title | |
tps: set[str] = set() | |
if not use_concrete: | |
tps.add("SchemaBase") | |
# NOTE: To keep type hints simple, we annotate with `SchemaBase` for all subclasses. | |
if title in tp_param: | |
tps.add("Parameter") |
Relevant section of vega-lite-schema.json
{
"definitions": {
"Expr": {
"type": "string"
},
"ExprRef": {
"additionalProperties": false,
"properties": {
"expr": {
"description": "Vega expression (which can refer to Vega-Lite parameters).",
"type": "string"
}
},
"required": [
"expr"
],
"type": "object"
}
}
}
Most of altair.expr.core.py
Lines 31 to 240 in cabf1e6
def _js_repr(val) -> str: | |
"""Return a javascript-safe string representation of val.""" | |
if val is True: | |
return "true" | |
elif val is False: | |
return "false" | |
elif val is None: | |
return "null" | |
elif isinstance(val, OperatorMixin): | |
return val._to_expr() | |
else: | |
return repr(val) | |
# Designed to work with Expression and VariableParameter | |
class OperatorMixin: | |
def _to_expr(self) -> str: | |
return repr(self) | |
def _from_expr(self, expr) -> Any: | |
return expr | |
def __add__(self, other): | |
comp_value = BinaryExpression("+", self, other) | |
return self._from_expr(comp_value) | |
def __radd__(self, other): | |
comp_value = BinaryExpression("+", other, self) | |
return self._from_expr(comp_value) | |
def __sub__(self, other): | |
comp_value = BinaryExpression("-", self, other) | |
return self._from_expr(comp_value) | |
def __rsub__(self, other): | |
comp_value = BinaryExpression("-", other, self) | |
return self._from_expr(comp_value) | |
def __mul__(self, other): | |
comp_value = BinaryExpression("*", self, other) | |
return self._from_expr(comp_value) | |
def __rmul__(self, other): | |
comp_value = BinaryExpression("*", other, self) | |
return self._from_expr(comp_value) | |
def __truediv__(self, other): | |
comp_value = BinaryExpression("/", self, other) | |
return self._from_expr(comp_value) | |
def __rtruediv__(self, other): | |
comp_value = BinaryExpression("/", other, self) | |
return self._from_expr(comp_value) | |
__div__ = __truediv__ | |
__rdiv__ = __rtruediv__ | |
def __mod__(self, other): | |
comp_value = BinaryExpression("%", self, other) | |
return self._from_expr(comp_value) | |
def __rmod__(self, other): | |
comp_value = BinaryExpression("%", other, self) | |
return self._from_expr(comp_value) | |
def __pow__(self, other): | |
# "**" Javascript operator is not supported in all browsers | |
comp_value = FunctionExpression("pow", (self, other)) | |
return self._from_expr(comp_value) | |
def __rpow__(self, other): | |
# "**" Javascript operator is not supported in all browsers | |
comp_value = FunctionExpression("pow", (other, self)) | |
return self._from_expr(comp_value) | |
def __neg__(self): | |
comp_value = UnaryExpression("-", self) | |
return self._from_expr(comp_value) | |
def __pos__(self): | |
comp_value = UnaryExpression("+", self) | |
return self._from_expr(comp_value) | |
# comparison operators | |
def __eq__(self, other): | |
comp_value = BinaryExpression("===", self, other) | |
return self._from_expr(comp_value) | |
def __ne__(self, other): | |
comp_value = BinaryExpression("!==", self, other) | |
return self._from_expr(comp_value) | |
def __gt__(self, other): | |
comp_value = BinaryExpression(">", self, other) | |
return self._from_expr(comp_value) | |
def __lt__(self, other): | |
comp_value = BinaryExpression("<", self, other) | |
return self._from_expr(comp_value) | |
def __ge__(self, other): | |
comp_value = BinaryExpression(">=", self, other) | |
return self._from_expr(comp_value) | |
def __le__(self, other): | |
comp_value = BinaryExpression("<=", self, other) | |
return self._from_expr(comp_value) | |
def __abs__(self): | |
comp_value = FunctionExpression("abs", (self,)) | |
return self._from_expr(comp_value) | |
# logical operators | |
def __and__(self, other): | |
comp_value = BinaryExpression("&&", self, other) | |
return self._from_expr(comp_value) | |
def __rand__(self, other): | |
comp_value = BinaryExpression("&&", other, self) | |
return self._from_expr(comp_value) | |
def __or__(self, other): | |
comp_value = BinaryExpression("||", self, other) | |
return self._from_expr(comp_value) | |
def __ror__(self, other): | |
comp_value = BinaryExpression("||", other, self) | |
return self._from_expr(comp_value) | |
def __invert__(self): | |
comp_value = UnaryExpression("!", self) | |
return self._from_expr(comp_value) | |
class Expression(OperatorMixin, SchemaBase): | |
""" | |
Expression. | |
Base object for enabling build-up of Javascript expressions using | |
a Python syntax. Calling ``repr(obj)`` will return a Javascript | |
representation of the object and the operations it encodes. | |
""" | |
_schema = {"type": "string"} | |
def to_dict(self, *args, **kwargs): | |
return repr(self) | |
def __setattr__(self, attr, val) -> None: | |
# We don't need the setattr magic defined in SchemaBase | |
return object.__setattr__(self, attr, val) | |
# item access | |
def __getitem__(self, val): | |
return GetItemExpression(self, val) | |
class UnaryExpression(Expression): | |
def __init__(self, op, val) -> None: | |
super().__init__(op=op, val=val) | |
def __repr__(self): | |
return f"({self.op}{_js_repr(self.val)})" | |
class BinaryExpression(Expression): | |
def __init__(self, op, lhs, rhs) -> None: | |
super().__init__(op=op, lhs=lhs, rhs=rhs) | |
def __repr__(self): | |
return f"({_js_repr(self.lhs)} {self.op} {_js_repr(self.rhs)})" | |
class FunctionExpression(Expression): | |
def __init__(self, name, args) -> None: | |
super().__init__(name=name, args=args) | |
def __repr__(self): | |
args = ",".join(_js_repr(arg) for arg in self.args) | |
return f"{self.name}({args})" | |
class ConstExpression(Expression): | |
def __init__(self, name) -> None: | |
super().__init__(name=name) | |
def __repr__(self) -> str: | |
return str(self.name) | |
class GetAttrExpression(Expression): | |
def __init__(self, group, name) -> None: | |
super().__init__(group=group, name=name) | |
def __repr__(self): | |
return f"{self.group}.{self.name}" | |
class GetItemExpression(Expression): | |
def __init__(self, group, name) -> None: | |
super().__init__(group=group, name=name) | |
def __repr__(self) -> str: | |
return f"{self.group}[{self.name!r}]" | |
IntoExpression: TypeAlias = Union[bool, None, str, float, OperatorMixin, Dict[str, Any]] |
Some inconsistency in v5.api.param
Highlighting this since it is the last step for the preferred functional wrappers
altair/altair/vegalite/v5/api.py
Lines 1316 to 1321 in cabf1e6
def param( | |
name: str | None = None, | |
value: Optional[Any] = Undefined, | |
bind: Optional[Binding] = Undefined, | |
empty: Optional[bool] = Undefined, | |
expr: Optional[str | Expr | Expression] = Undefined, |
core.VariableParameter.expr
exists
altair/altair/vegalite/v5/api.py
Lines 1381 to 1387 in cabf1e6
parameter.param = core.VariableParameter( | |
name=parameter.name, | |
bind=bind, | |
value=value, | |
expr=expr, | |
**kwds, | |
) |
core.TopLevelSelectionParameter.expr
doesn't exist
altair/altair/vegalite/v5/api.py
Lines 1390 to 1392 in cabf1e6
parameter.param = core.TopLevelSelectionParameter( | |
name=parameter.name, bind=bind, value=value, expr=expr, **kwds | |
) |
core.SelectionParameter.expr
doesn't exist
altair/altair/vegalite/v5/api.py
Lines 1395 to 1397 in cabf1e6
parameter.param = core.SelectionParameter( | |
name=parameter.name, bind=bind, value=value, expr=expr, **kwds | |
) |
Question
Does this mean a defining trait of a VariableParameter
is that it wraps an expression?
Usage
Issues/PRs
- Upgrade
alt.expr
to include behaviour ofalt.ExprRef
#2880 - Include
alt.ExprRef
capabilities inalt.expr()
#2886
Summary
- We use
Parameter
as the annotation forExprRef
. - We do not annotate
Expr
in generated code- It is just an alias for
str
- It is just an alias for
Expression
(and subclasses) representExpr
expr
return types (as of feat: Generateexpr
method signatures, docs #3600 (comment))alt.expr(...)
->ExprRef
alt.expr.method(...)
->Expression
alt.expr.property
->Expression
@mattijn would you be okay if we renamed this issue? Modifying the code used in #3614 example would be a benefit. My proposal of |
Sure, issue can be renamed! |
Expression
|expr
|Expr
|ExprRef
) UX & ergonomics
I've started exploring some of #3616 (comment) What I found really surprised me. TestingI made this small change, which simply adds diff
|
@override | |
def __new__(cls: type[_ExprRef], expr: str) -> _ExprRef: # type: ignore[misc] | |
# NOTE: `mypy<=1.10.1` is not consistent with typing spec | |
# https://github.com/python/mypy/issues/1020 | |
# https://docs.python.org/3/reference/datamodel.html#object.__new__ | |
# https://typing.readthedocs.io/en/latest/spec/constructors.html#new-method | |
return _ExprRef(expr=expr) |
Spotted while investigating #3616 (comment) When I originally wrote this test, I didn't realise I was comparing: ```py GetAttrExpression(Parameter.name, "expr") == GetAttrExpression(Parameter.name, "expr") ``` The intention was to compare the contents were the same. Due to `OperatorMixin.__eq__`, the previous assertion would always return `True`.
- Initial use-case is `SelectionPredicateComposition` -> `PredicateComposition` - Something similar might be a good idea for `Expression` -> `Expr` in #3616
What is your suggestion?
As requested by @dangotbanned here: #3614 (review). #3614 uses an expression as a
str
with plain Vega Expression syntax. It would be nice to use the equivalent python syntax instead.Have you considered any alternative solutions?
No response
The text was updated successfully, but these errors were encountered: