Skip to content
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

feat(typing): Adds public altair.typing module #3515

Merged
merged 36 commits into from
Aug 8, 2024
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
680b69e
feat(typing): Create `altair.typing`
dangotbanned Aug 3, 2024
785f89c
chore: Add comment on `tooltip` annotation
dangotbanned Aug 3, 2024
5423748
feat(typing): Add reference impl `EncodeKwds` from comment
dangotbanned Aug 3, 2024
da64738
feat(typing): Use `OneOrSeq[tps]` instead of `Union[*tps, list]` in `…
dangotbanned Aug 3, 2024
5877b38
build: run `generate-schema-wrapper`
dangotbanned Aug 3, 2024
4fe5f3a
wip: generate `typed_dict_args`
dangotbanned Aug 3, 2024
0014cc8
refactor: Simplify `tools`, remove redundant code
dangotbanned Aug 3, 2024
226038d
refactor: finish removing `altair_classes_prefix`
dangotbanned Aug 3, 2024
77b101a
feat: `_create_encode_signature()` -> `EncodingArtifacts`
dangotbanned Aug 3, 2024
675bc4e
build: run `generate-schema-wrapper`
dangotbanned Aug 3, 2024
51a84a5
feat(typing): Provide a public export for `_EncodeKwds`
dangotbanned Aug 3, 2024
2ef4b0f
Merge branch 'main' into public-typing
dangotbanned Aug 3, 2024
4e0a098
Merge branch 'main' into pr/dangotbanned/3515
binste Aug 4, 2024
2b9ad2c
Add docstring to _EncodeKwds
binste Aug 4, 2024
79f317d
Rewrite EncodeArtifacts dataclass as a function
binste Aug 4, 2024
1eb466d
Fix ruff issue due to old local ruff version
binste Aug 4, 2024
0287eba
Change generate_encoding_artifacts to an iterator
binste Aug 4, 2024
bac1f67
docs: run `generate-schema-wrapper` with `indent_level=4`
dangotbanned Aug 4, 2024
3419250
feat(typing): Move `ChartType`, `is_chart_type` to `alt.typing`
dangotbanned Aug 4, 2024
5321b4b
Merge remote-tracking branch 'upstream/main' into public-typing
dangotbanned Aug 4, 2024
d16ec34
revert(ruff): Restore original ('RUF001`) line
dangotbanned Aug 4, 2024
e903528
Add type aliases for each channel
binste Aug 5, 2024
6662fc9
Format
binste Aug 5, 2024
28de27b
Use Union instead of | for compatibility with Py <3.10
binste Aug 5, 2024
b3fbe9c
Add channel type aliases to typing module. Add 'Type hints' section t…
binste Aug 6, 2024
5ba8a8d
chore(ruff): Remove unused `F401` ignore
dangotbanned Aug 6, 2024
49122b1
feat(typing): Move `Optional` export to `typing`
dangotbanned Aug 6, 2024
fe22c80
refactor: Move blank line append to `indent_docstring`
dangotbanned Aug 6, 2024
d3daf51
docs(typing): Remove empty type list from `EncodeKwds`
dangotbanned Aug 6, 2024
914428a
refactor: Renaming, grouping, reducing repetition
dangotbanned Aug 6, 2024
11c58c3
refactor: More tidying up, annotating, reformat
dangotbanned Aug 6, 2024
067f455
docs: Reference aliases in `generate_encoding_artifacts`
dangotbanned Aug 6, 2024
6fefd12
Use full type hints instead of type alias in signatures for typeddict…
binste Aug 7, 2024
9299a81
Merge remote-tracking branch 'upstream/main' into public-typing
dangotbanned Aug 7, 2024
b6f84e4
Rename 'Type hints' to 'Typing'
binste Aug 8, 2024
d4313c0
Ruff fix
binste Aug 8, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions altair/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,7 @@
"to_json",
"to_values",
"topo_feature",
"typing",
"utils",
"v5",
"value",
Expand All @@ -654,6 +655,7 @@ def __dir__():
from altair.jupyter import JupyterChart
from altair.expr import expr
from altair.utils import AltairDeprecationWarning, parse_shorthand, Optional, Undefined
from altair import typing
binste marked this conversation as resolved.
Show resolved Hide resolved


def load_ipython_extension(ipython):
Expand Down
63 changes: 63 additions & 0 deletions altair/typing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
from __future__ import annotations

import sys
from typing import Any, Mapping, Union
from typing_extensions import TypedDict

if sys.version_info >= (3, 10):
from typing import TypeAlias
else:
from typing_extensions import TypeAlias

ChannelType: TypeAlias = Union[str, Mapping[str, Any], Any]


class EncodeKwds(TypedDict, total=False):
"""
Reference implementation from [comment code block](https://github.com/pola-rs/polars/pull/17995#issuecomment-2263609625).

Aiming to define more specific `ChannelType`, without being exact.

This would be generated alongside `tools.generate_schema_wrapper._create_encode_signature`
"""

angle: ChannelType
color: ChannelType
column: ChannelType
description: ChannelType
detail: ChannelType | list[Any]
facet: ChannelType
fill: ChannelType
fillOpacity: ChannelType
href: ChannelType
key: ChannelType
latitude: ChannelType
latitude2: ChannelType
longitude: ChannelType
longitude2: ChannelType
opacity: ChannelType
order: ChannelType | list[Any]
radius: ChannelType
radius2: ChannelType
row: ChannelType
shape: ChannelType
size: ChannelType
stroke: ChannelType
strokeDash: ChannelType
strokeOpacity: ChannelType
strokeWidth: ChannelType
text: ChannelType
theta: ChannelType
theta2: ChannelType
tooltip: ChannelType | list[Any]
binste marked this conversation as resolved.
Show resolved Hide resolved
url: ChannelType
x: ChannelType
x2: ChannelType
xError: ChannelType
xError2: ChannelType
xOffset: ChannelType
y: ChannelType
y2: ChannelType
yError: ChannelType
yError2: ChannelType
yOffset: ChannelType
6 changes: 3 additions & 3 deletions altair/vegalite/v5/schema/channels.py
Original file line number Diff line number Diff line change
Expand Up @@ -30905,7 +30905,7 @@ def encode(
color: Optional[str | Color | Map | ColorDatum | ColorValue] = Undefined,
column: Optional[str | Column | Map] = Undefined,
description: Optional[str | Description | Map | DescriptionValue] = Undefined,
detail: Optional[str | Detail | Map | list] = Undefined,
detail: Optional[OneOrSeq[str | Detail | Map]] = Undefined,
facet: Optional[str | Facet | Map] = Undefined,
fill: Optional[str | Fill | Map | FillDatum | FillValue] = Undefined,
fillOpacity: Optional[
Expand All @@ -30924,7 +30924,7 @@ def encode(
opacity: Optional[
str | Opacity | Map | OpacityDatum | OpacityValue
] = Undefined,
order: Optional[str | Order | Map | list | OrderValue] = Undefined,
order: Optional[OneOrSeq[str | Order | Map | OrderValue]] = Undefined,
radius: Optional[str | Radius | Map | RadiusDatum | RadiusValue] = Undefined,
radius2: Optional[
str | Radius2 | Map | Radius2Datum | Radius2Value
Expand All @@ -30945,7 +30945,7 @@ def encode(
text: Optional[str | Text | Map | TextDatum | TextValue] = Undefined,
theta: Optional[str | Theta | Map | ThetaDatum | ThetaValue] = Undefined,
theta2: Optional[str | Theta2 | Map | Theta2Datum | Theta2Value] = Undefined,
tooltip: Optional[str | Tooltip | Map | list | TooltipValue] = Undefined,
tooltip: Optional[OneOrSeq[str | Tooltip | Map | TooltipValue]] = Undefined,
binste marked this conversation as resolved.
Show resolved Hide resolved
url: Optional[str | Url | Map | UrlValue] = Undefined,
x: Optional[str | X | Map | XDatum | XValue] = Undefined,
x2: Optional[str | X2 | Map | X2Datum | X2Value] = Undefined,
Expand Down
193 changes: 100 additions & 93 deletions tools/generate_schema_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ def configure_{prop}(self, *args, **kwargs) -> Self:

ENCODE_METHOD: Final = '''
class _EncodingMixin:
def encode({encode_method_args}) -> Self:
def encode({method_args}) -> Self:
"""Map properties of the data to visual properties of the chart (see :class:`FacetedEncoding`)
{docstring}"""
# Compat prep for `infer_encoding_types` signature
Expand All @@ -233,6 +233,13 @@ def encode({encode_method_args}) -> Self:
return copy
'''

ENCODE_TYPED_DICT: Final = '''
class _EncodeKwds(TypedDict, total=False):
"""{docstring}"""
{channels}

'''

# NOTE: Not yet reasonable to generalize `TypeAliasType`, `TypeVar`
# Revisit if this starts to become more common
TYPING_EXTRA: Final = '''
Expand Down Expand Up @@ -562,18 +569,29 @@ def _type_checking_only_imports(*imports: str) -> str:
class ChannelInfo:
supports_arrays: bool
deep_description: str
field_class_name: str | None = None
field_class_name: str
datum_class_name: str | None = None
value_class_name: str | None = None

@property
def is_field_only(self) -> bool:
return not (self.datum_class_name or self.value_class_name)

@property
def all_names(self) -> Iterator[str]:
if self.field_class_name:
yield self.field_class_name
if self.datum_class_name:
yield self.datum_class_name
if self.value_class_name:
yield self.value_class_name
"""All channels are expected to have a field class."""
yield self.field_class_name
yield from self.non_field_names

@property
def non_field_names(self) -> Iterator[str]:
if self.is_field_only:
yield from ()
else:
if self.datum_class_name:
yield self.datum_class_name
if self.value_class_name:
yield self.value_class_name


def generate_vegalite_channel_wrappers(
Expand All @@ -595,50 +613,37 @@ def generate_vegalite_channel_wrappers(
supports_arrays = any(
schema_info.is_array() for schema_info in propschema.anyOf
)
classname: str = prop[0].upper() + prop[1:]
channel_info = ChannelInfo(
supports_arrays=supports_arrays,
deep_description=propschema.deep_description,
field_class_name=classname,
)

for encoding_spec, definition in def_dict.items():
classname = prop[0].upper() + prop[1:]
basename = definition.rsplit("/", maxsplit=1)[-1]
basename = get_valid_identifier(basename)

gen: SchemaGenerator
defschema = {"$ref": definition}

Generator: (
type[FieldSchemaGenerator]
| type[DatumSchemaGenerator]
| type[ValueSchemaGenerator]
)
kwds = {
"basename": basename,
"schema": defschema,
"rootschema": schema,
"encodingname": prop,
"haspropsetters": True,
}
if encoding_spec == "field":
Generator = FieldSchemaGenerator
nodefault = []
channel_info.field_class_name = classname

gen = FieldSchemaGenerator(classname, nodefault=[], **kwds)
elif encoding_spec == "datum":
Generator = DatumSchemaGenerator
classname += "Datum"
nodefault = ["datum"]
channel_info.datum_class_name = classname

temp_name = f"{classname}Datum"
channel_info.datum_class_name = temp_name
gen = DatumSchemaGenerator(temp_name, nodefault=["datum"], **kwds)
elif encoding_spec == "value":
Generator = ValueSchemaGenerator
classname += "Value"
nodefault = ["value"]
channel_info.value_class_name = classname

gen = Generator(
classname=classname,
basename=basename,
schema=defschema,
rootschema=schema,
encodingname=prop,
nodefault=nodefault,
haspropsetters=True,
altair_classes_prefix="core",
)
temp_name = f"{classname}Value"
channel_info.value_class_name = temp_name
gen = ValueSchemaGenerator(temp_name, nodefault=["value"], **kwds)

class_defs.append(gen.schema_class())

channel_infos[prop] = channel_info
Expand All @@ -656,7 +661,7 @@ def generate_vegalite_channel_wrappers(

imports = imports or [
"from __future__ import annotations\n",
"from typing import Any, overload, Sequence, List, Literal, Union, TYPE_CHECKING",
"from typing import Any, overload, Sequence, List, Literal, Union, TYPE_CHECKING, TypedDict",
"from narwhals.dependencies import is_pandas_dataframe as _is_pandas_dataframe",
"from altair.utils.schemapi import Undefined, with_property_setters",
"from altair.utils import infer_encoding_types as _infer_encoding_types",
Expand All @@ -676,11 +681,8 @@ def generate_vegalite_channel_wrappers(
"\n" f"__all__ = {sorted(all_)}\n",
CHANNEL_MIXINS,
*class_defs,
*EncodingArtifacts(channel_infos, ENCODE_METHOD, ENCODE_TYPED_DICT),
]

# Generate the type signature for the encode method
encode_signature = _create_encode_signature(channel_infos)
contents.append(encode_signature)
return "\n".join(contents)


Expand Down Expand Up @@ -861,59 +863,64 @@ def vegalite_main(skip_download: bool = False) -> None:
ruff_write_lint_format_str(fp, contents)


def _create_encode_signature(
channel_infos: dict[str, ChannelInfo],
) -> str:
signature_args: list[str] = ["self", "*args: Any"]
docstring_parameters: list[str] = ["", "Parameters", "----------"]
for channel, info in channel_infos.items():
field_class_name = info.field_class_name
assert (
field_class_name is not None
), "All channels are expected to have a field class"
datum_and_value_class_names = []
if info.datum_class_name is not None:
datum_and_value_class_names.append(info.datum_class_name)

if info.value_class_name is not None:
datum_and_value_class_names.append(info.value_class_name)

# dict stands for the return types of alt.datum, alt.value as well as
# the dictionary representation of an encoding channel class. See
# discussions in https://github.com/vega/altair/pull/3208
# for more background.
union_types = ["str", field_class_name, "Map"]
docstring_union_types = ["str", rst_syntax_for_class(field_class_name), "Dict"]
if info.supports_arrays:
# We could be more specific about what types are accepted in the list
# but then the signatures would get rather long and less useful
# to a user when it shows up in their IDE.
union_types.append("list")
docstring_union_types.append("List")

union_types = union_types + datum_and_value_class_names
docstring_union_types = docstring_union_types + [
rst_syntax_for_class(c) for c in datum_and_value_class_names
]
@dataclass
class EncodingArtifacts:
"""
Wrapper for what was previously `_create_encode_signature()`.

signature_args.append(
f"{channel}: Optional[Union[{', '.join(union_types)}]] = Undefined"
)
Now also creates a `TypedDict` as part of https://github.com/pola-rs/polars/pull/17995
"""

docstring_parameters.extend(
(
f"{channel} : {', '.join(docstring_union_types)}",
f" {process_description(info.deep_description)}",
channel_infos: dict[str, ChannelInfo]
fmt_method: str
fmt_typed_dict: str

def __iter__(self) -> Iterator[str]:
dangotbanned marked this conversation as resolved.
Show resolved Hide resolved
"""After construction, this allows for unpacking (`*`)."""
yield from self._gen_artifacts()

def _gen_artifacts(self) -> None:
"""
Generate `.encode()` related things.

Notes
-----
- `Map`/`Dict` stands for the return types of `alt.(datum|value)`, and any encoding channel class.
- See discussions in https://github.com/vega/altair/pull/3208
- We could be more specific about what types are accepted in the `List`
- but this translates poorly to an IDE
- `info.supports_arrays`
"""
signature_args: list[str] = ["self", "*args: Any"]
docstring_parameters: list[str] = ["", "Parameters", "----------"]
typed_dict_args: list[str] = []
for channel, info in self.channel_infos.items():
it = info.all_names
it_rst_names = (rst_syntax_for_class(c) for c in info.all_names)

docstring_union_types = ["str", next(it_rst_names), "Dict"]
tp_inner: str = " | ".join(chain(("str", next(it), "Map"), it))
if info.supports_arrays:
docstring_union_types.append("List")
tp_inner = f"OneOrSeq[{tp_inner}]"
signature_args.append(f"{channel}: Optional[{tp_inner}] = Undefined")
typed_dict_args.append(f"{channel}: {tp_inner}")
docstring_parameters.extend(
(
f"{channel} : {', '.join(chain(docstring_union_types, it_rst_names))}",
f" {process_description(info.deep_description)}",
)
)
)
if len(docstring_parameters) > 1:
docstring_parameters += [""]
docstring = indent_docstring(
docstring_parameters, indent_level=8, width=100, lstrip=False
)
return ENCODE_METHOD.format(
encode_method_args=", ".join(signature_args), docstring=docstring
)
doc = indent_docstring(
docstring_parameters, indent_level=8, width=100, lstrip=False
)
yield self.fmt_method.format(
method_args=", ".join(signature_args), docstring=doc
)
yield self.fmt_typed_dict.format(
channels="\n ".join(typed_dict_args), docstring="Placeholder (FIXME)"
)


def main() -> None:
Expand Down
Loading