diff --git a/altair/utils/core.py b/altair/utils/core.py index c8b47f9f8..7e8340324 100644 --- a/altair/utils/core.py +++ b/altair/utils/core.py @@ -12,7 +12,16 @@ from copy import deepcopy from itertools import groupby from operator import itemgetter -from typing import TYPE_CHECKING, Any, Callable, Iterator, Literal, TypeVar, cast +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Iterator, + Literal, + TypeVar, + cast, + overload, +) import jsonschema import narwhals.stable.v1 as nw @@ -22,13 +31,13 @@ from altair.utils.schemapi import SchemaBase, Undefined if sys.version_info >= (3, 12): - from typing import Protocol, runtime_checkable + from typing import Protocol, TypeAliasType, runtime_checkable else: - from typing_extensions import Protocol, runtime_checkable + from typing_extensions import Protocol, TypeAliasType, runtime_checkable if sys.version_info >= (3, 10): - from typing import ParamSpec + from typing import Concatenate, ParamSpec else: - from typing_extensions import ParamSpec + from typing_extensions import Concatenate, ParamSpec if TYPE_CHECKING: @@ -40,9 +49,21 @@ from altair.utils._dfi_types import DataFrame as DfiDataFrame from altair.vegalite.v5.schema._typing import StandardType_T as InferredVegaLiteType -V = TypeVar("V") -P = ParamSpec("P") TIntoDataFrame = TypeVar("TIntoDataFrame", bound=IntoDataFrame) +T = TypeVar("T") +P = ParamSpec("P") +R = TypeVar("R") + +WrapsFunc = TypeAliasType("WrapsFunc", Callable[..., R], type_params=(R,)) +WrappedFunc = TypeAliasType("WrappedFunc", Callable[P, R], type_params=(P, R)) +# NOTE: Requires stringized form to avoid `< (3, 11)` issues +# See: https://github.com/vega/altair/actions/runs/10667859416/job/29567290871?pr=3565 +WrapsMethod = TypeAliasType( + "WrapsMethod", "Callable[Concatenate[T, ...], R]", type_params=(T, R) +) +WrappedMethod = TypeAliasType( + "WrappedMethod", Callable[Concatenate[T, P], R], type_params=(T, P, R) +) @runtime_checkable @@ -708,31 +729,43 @@ def infer_vegalite_type_for_narwhals( raise ValueError(msg) -def use_signature(obj: Callable[P, Any]): # -> Callable[..., Callable[P, V]]: - """Apply call signature and documentation of `obj` to the decorated method.""" +def use_signature(tp: Callable[P, Any], /): + """ + Use the signature and doc of ``tp`` for the decorated callable ``cb``. - def decorate(func: Callable[..., V]) -> Callable[P, V]: - # call-signature of func is exposed via __wrapped__. - # we want it to mimic obj.__init__ + - **Overload 1**: Decorating method + - **Overload 2**: Decorating function - # error: Accessing "__init__" on an instance is unsound, - # since instance.__init__ could be from an incompatible subclass [misc] - wrapped = ( - obj.__init__ if (isinstance(obj, type) and issubclass(obj, object)) else obj # type: ignore [misc] - ) - func.__wrapped__ = wrapped # type: ignore[attr-defined] - func._uses_signature = obj # type: ignore[attr-defined] - - # Supplement the docstring of func with information from obj - if doc_in := obj.__doc__: - doc_lines = doc_in.splitlines(keepends=True)[1:] - # Patch in a reference to the class this docstring is copied from, - # to generate a hyperlink. - line_1 = f"{func.__doc__ or f'Refer to :class:`{obj.__name__}`'}\n" - func.__doc__ = "".join((line_1, *doc_lines)) - return func + Returns + ------- + **Adding the annotation breaks typing**: + + Overload[Callable[[WrapsMethod[T, R]], WrappedMethod[T, P, R]], Callable[[WrapsFunc[R]], WrappedFunc[P, R]]] + """ + + @overload + def decorate(cb: WrapsMethod[T, R], /) -> WrappedMethod[T, P, R]: ... + + @overload + def decorate(cb: WrapsFunc[R], /) -> WrappedFunc[P, R]: ... + + def decorate(cb: WrapsFunc[R], /) -> WrappedMethod[T, P, R] | WrappedFunc[P, R]: + """ + Raises when no doc was found. + + Notes + ----- + - Reference to ``tp`` is stored in ``cb.__wrapped__``. + - The doc for ``cb`` will have a ``.rst`` link added, referring to ``tp``. + """ + cb.__wrapped__ = getattr(tp, "__init__", tp) # type: ignore[attr-defined] + + if doc_in := tp.__doc__: + line_1 = f"{cb.__doc__ or f'Refer to :class:`{tp.__name__}`'}\n" + cb.__doc__ = "".join((line_1, *doc_in.splitlines(keepends=True)[1:])) + return cb else: - msg = f"Found no doc for {obj!r}" + msg = f"Found no doc for {tp!r}" raise AttributeError(msg) return decorate diff --git a/altair/vegalite/v5/api.py b/altair/vegalite/v5/api.py index 95b670d2f..b437c1072 100644 --- a/altair/vegalite/v5/api.py +++ b/altair/vegalite/v5/api.py @@ -308,11 +308,26 @@ def to_dict(self, *args: Any, **kwargs: Any) -> dict[str, Any]: class FacetMapping(core.FacetMapping): + """ + FacetMapping schema wrapper. + + Parameters + ---------- + column : str, :class:`FacetFieldDef`, :class:`Column` + A field definition for the horizontal facet of trellis plots. + row : str, :class:`FacetFieldDef`, :class:`Row` + A field definition for the vertical facet of trellis plots. + """ + _class_is_valid_at_instantiation = False - @utils.use_signature(core.FacetMapping) - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) + def __init__( + self, + column: Optional[str | FacetFieldDef | Column] = Undefined, + row: Optional[str | FacetFieldDef | Row] = Undefined, + **kwargs: Any, + ) -> None: + super().__init__(column=column, row=row, **kwargs) # type: ignore[arg-type] def to_dict(self, *args: Any, **kwargs: Any) -> dict[str, Any]: copy = self.copy(deep=False) @@ -3606,13 +3621,14 @@ def facet( self = _top_schema_base(self).copy(deep=False) data, self.data = self.data, Undefined - if facet_specified: + f: Facet | FacetMapping + if not utils.is_undefined(facet): f = channels.Facet(facet) if isinstance(facet, str) else facet else: r: Any = row f = FacetMapping(row=r, column=column) - return FacetChart(spec=self, facet=f, data=data, columns=columns, **kwargs) + return FacetChart(spec=self, facet=f, data=data, columns=columns, **kwargs) # pyright: ignore[reportArgumentType] class Chart( @@ -4162,7 +4178,7 @@ def add_selection(self, *selections) -> Self: # noqa: ANN002 def concat(*charts: ConcatType, **kwargs: Any) -> ConcatChart: """Concatenate charts horizontally.""" - return ConcatChart(concat=charts, **kwargs) # pyright: ignore + return ConcatChart(concat=charts, **kwargs) class HConcatChart(TopLevelMixin, core.TopLevelHConcatSpec): @@ -4266,7 +4282,7 @@ def add_selection(self, *selections) -> Self: # noqa: ANN002 def hconcat(*charts: ConcatType, **kwargs: Any) -> HConcatChart: """Concatenate charts horizontally.""" - return HConcatChart(hconcat=charts, **kwargs) # pyright: ignore + return HConcatChart(hconcat=charts, **kwargs) class VConcatChart(TopLevelMixin, core.TopLevelVConcatSpec): @@ -4372,7 +4388,7 @@ def add_selection(self, *selections) -> Self: # noqa: ANN002 def vconcat(*charts: ConcatType, **kwargs: Any) -> VConcatChart: """Concatenate charts vertically.""" - return VConcatChart(vconcat=charts, **kwargs) # pyright: ignore + return VConcatChart(vconcat=charts, **kwargs) class LayerChart(TopLevelMixin, _EncodingMixin, core.TopLevelLayerSpec): @@ -4498,7 +4514,7 @@ def add_selection(self, *selections) -> Self: # noqa: ANN002 def layer(*charts: LayerType, **kwargs: Any) -> LayerChart: """Layer multiple charts.""" - return LayerChart(layer=charts, **kwargs) # pyright: ignore + return LayerChart(layer=charts, **kwargs) class FacetChart(TopLevelMixin, core.TopLevelFacetSpec): diff --git a/altair/vegalite/v5/schema/core.py b/altair/vegalite/v5/schema/core.py index 0892e7214..dc82b1c95 100644 --- a/altair/vegalite/v5/schema/core.py +++ b/altair/vegalite/v5/schema/core.py @@ -158,7 +158,6 @@ "ExtentTransform", "FacetEncodingFieldDef", "FacetFieldDef", - "FacetMapping", "FacetSpec", "FacetedEncoding", "FacetedUnitSpec", diff --git a/tools/generate_schema_wrapper.py b/tools/generate_schema_wrapper.py index a625394bd..8ee730550 100644 --- a/tools/generate_schema_wrapper.py +++ b/tools/generate_schema_wrapper.py @@ -537,7 +537,7 @@ def generate_vegalite_schema_wrapper(schema_file: Path) -> str: # of exported classes which are also defined in the channels or api modules which takes # precedent in the generated __init__.py files one and two levels up. # Importing these classes from multiple modules confuses type checkers. - EXCLUDE = {"Color", "Text", "LookupData", "Dict"} + EXCLUDE = {"Color", "Text", "LookupData", "Dict", "FacetMapping"} it = (c for c in definitions.keys() - EXCLUDE if not c.startswith("_")) all_ = [*sorted(it), "Root", "VegaLiteSchema", "SchemaBase", "load_schema"]