blob: cbcc1a9ac08d63ed434a8f8220bf49511a60e584 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Protocols and value types for installing extensions on a session context.
An *extension* is a reusable configuration object — typically shipped by a
separate compiled library — that contributes components to a
:py:class:`~datafusion.context.SessionContext`. It implements
:py:class:`SessionComponentsExportable` by returning a
:py:class:`SessionExtensionComponents` describing what it contributes, and is
installed with :py:meth:`~datafusion.context.SessionContext.with_extensions`::
ctx = SessionContext().with_extensions(MyLibraryExtension())
Codecs and planners install in two phases: every
:py:class:`SessionComponentsExportable` runs first and its codecs are installed,
then every :py:class:`SessionPlannerExportable` runs in argument order. A bundle
implements either hook or both. Bundle order is significant for planners, which
nest, and irrelevant for codecs, which accumulate.
Of the five names here, only the two bundle hooks are ``@runtime_checkable``,
because :py:meth:`~datafusion.context.SessionContext.with_extensions`
dispatches on them from Python. :py:class:`QueryPlannerExportable` and
:py:class:`PhysicalOptimizerRuleExportable` are type hints only, matching the
other capsule-getter protocols in :py:mod:`datafusion.user_defined` and
:py:mod:`datafusion.catalog`.
See :ref:`extension_bundles` in the online documentation for why the phases are
split and for a worked implementation.
"""
from __future__ import annotations
from dataclasses import dataclass, fields
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
if TYPE_CHECKING:
import sys
if sys.version_info >= (3, 13):
from types import CapsuleType as _PyCapsule
else:
from typing_extensions import CapsuleType as _PyCapsule
from datafusion.context import SessionContext
from datafusion.user_defined import (
LogicalExtensionCodecExportable,
PhysicalExtensionCodecExportable,
)
__all__ = [
"PhysicalOptimizerRuleExportable",
"QueryPlannerExportable",
"SessionComponentsExportable",
"SessionExtensionComponents",
"SessionPlannerExportable",
]
class PhysicalOptimizerRuleExportable(Protocol):
"""Type hint for object that has __datafusion_physical_optimizer_rule__ PyCapsule.
The method returns a PyCapsule wrapping an ``FFI_PhysicalOptimizerRule``,
typically produced by a separate compiled extension. It takes **no
argument**: a rule needs neither a codec nor a task-context provider, so
there is nothing session-scoped to hand it.
Rules accumulate rather than replace. Install one with
:py:meth:`~datafusion.context.SessionContext.add_physical_optimizer_rule`
— see :ref:`extension_other_hooks`.
Examples:
The getter is the whole protocol, and a capsule is what it must return
— anything else is refused where it is installed rather than at plan
time:
>>> from datafusion import SessionContext
>>> ctx = SessionContext()
>>> ctx.add_physical_optimizer_rule(object())
Traceback (most recent call last):
...
RuntimeError: "Invalid datafusion_physical_optimizer_rule...
Real usage. Skipped here (needs a built extension library); parsed out
of this docstring and run for real by
``test_physical_optimizer_rule_docstring_example_still_runs`` in
``datafusion-ffi-example``.
>>> from datafusion_ffi_example import MyPhysicalOptimizerRule # doctest: +SKIP
>>> ctx.add_physical_optimizer_rule(MyPhysicalOptimizerRule()) # doctest: +SKIP
"""
def __datafusion_physical_optimizer_rule__(self) -> object: ... # noqa: D105
class QueryPlannerExportable(Protocol):
"""Type hint for object that has a __datafusion_query_planner__ PyCapsule.
The method returns a PyCapsule wrapping an ``FFI_QueryPlanner``, typically
produced by a separate compiled extension. ``session`` is a handle on the
session the planner is being installed on; take the extension codecs from
it rather than building your own, and duck-type it — see
:py:class:`~datafusion.user_defined.LogicalExtensionCodecExportable` for
``session``.
Unlike the two bundle hooks in this module, this protocol is a type hint
only: it is not ``@runtime_checkable``, so ``isinstance`` against it raises
``TypeError``.
Examples:
A :py:class:`~datafusion.context.SessionContext` satisfies this
protocol, which is what lets a foreign planner wrap the one a session
already has:
>>> from datafusion import SessionContext
>>> ctx = SessionContext()
>>> type(ctx.__datafusion_query_planner__(ctx)).__name__
'PyCapsule'
The protocol itself is not runtime-checkable:
>>> from datafusion.extensions import QueryPlannerExportable
>>> try:
... isinstance(ctx, QueryPlannerExportable)
... except TypeError as e:
... print("runtime_checkable" in str(e))
True
"""
def __datafusion_query_planner__(self, session: Any) -> object: ... # noqa: D105
def _not_a_codec_iterable(field: str, value: object) -> str:
"""Message for a codec field that cannot be read as a collection."""
return (
f"{field} must be an iterable of codec objects, not a single "
f"{type(value).__name__}. A lone codec is written as a one-element "
f"tuple — {field}=(codec,) — and the trailing comma is what makes it one."
)
@dataclass(frozen=True, kw_only=True)
class SessionExtensionComponents:
"""Components an extension contributes to a session context.
Returned by :py:meth:`SessionComponentsExportable.__datafusion_session_components__`
and consumed by
:py:meth:`~datafusion.context.SessionContext.with_extensions`. Every
component must be created against the context passed to that method;
components bound to a different session hold a task-context provider for
that other session and cannot be rebound.
Construction is keyword-only, so later releases can add component kinds
without changing what an existing call means.
Query planners are not listed here. They install in a second phase so each
can wrap the one before it — see :py:class:`SessionPlannerExportable`.
Examples:
A bundle that contributes no codecs is valid — a planner-only library
returns this, or omits the hook entirely:
>>> from datafusion import SessionExtensionComponents
>>> components = SessionExtensionComponents()
>>> components.logical_extension_codecs
()
A bundle that contributes one kind of component names it, leaving the
rest empty. Here the codec is a capsule wrapped in an object that
declares the id its payloads will carry:
>>> from datafusion import SessionContext
>>> class NamedCodec:
... __datafusion_codec_id__ = "my_library.v1"
...
... def __init__(self, capsule):
... self._capsule = capsule
...
... def __datafusion_logical_extension_codec__(self, session=None):
... return self._capsule
>>> ctx = SessionContext()
>>> components = SessionExtensionComponents(
... logical_extension_codecs=(NamedCodec(
... ctx.__datafusion_logical_extension_codec__()
... ),)
... )
>>> components.logical_extension_codecs[0].__datafusion_codec_id__
'my_library.v1'
>>> components.physical_extension_codecs
()
A single codec is not an iterable of codecs, and forgetting the
trailing comma is the easy way to write one by accident:
>>> SessionExtensionComponents(logical_extension_codecs=NamedCodec(ctx))
Traceback (most recent call last):
...
TypeError: logical_extension_codecs must be an iterable of codec objects...
"""
logical_extension_codecs: tuple[LogicalExtensionCodecExportable, ...] = ()
"""Logical codecs to add to the session's codec chain, in declaration order.
Objects exposing ``__datafusion_logical_extension_codec__``, never bare
``PyCapsule`` objects — a codec's id is read off the object it is handed
over as. Any iterable is accepted and stored as a tuple. See
:ref:`extension_bundles_codecs_are_objects`.
"""
physical_extension_codecs: tuple[PhysicalExtensionCodecExportable, ...] = ()
"""Physical codecs to add to the session's codec chain, in declaration order.
As :py:attr:`logical_extension_codecs`, for
``__datafusion_physical_extension_codec__``.
"""
def __post_init__(self) -> None:
"""Normalize each codec field to a tuple, rejecting what cannot become one."""
# A bundle that writes `logical_extension_codecs=codec` instead of
# `(codec,)` is contributing one codec, not an iterable of them.
# Without this, the mistake surfaces inside `with_extensions` as
# `'MyCodec' object is not iterable`, which names neither the field nor
# the hook that built it. Checking here puts the error in the extension
# library's own frame.
#
# Normalizing is worth doing on its own: the declared type is a tuple
# and the class is frozen, so a list left in place would be a mutable
# member of an immutable value, and a generator would be exhausted by
# the first read.
#
# Driven off `dataclasses.fields` rather than a written-out list, so a
# codec field added later is normalized without anyone remembering to
# name it here. The `_codecs` suffix is what marks a field as one of
# them, leaving room for a future field that is not a codec collection
# and must not be turned into a tuple.
for field in fields(self):
name = field.name
if not name.endswith("_codecs"):
continue
value = getattr(self, name)
# A str is iterable, so it would otherwise normalize into a tuple
# of characters and fail much later as that many bogus codecs.
if isinstance(value, (str, bytes)):
raise TypeError(_not_a_codec_iterable(name, value))
try:
codecs = tuple(value)
except TypeError:
raise TypeError(_not_a_codec_iterable(name, value)) from None
object.__setattr__(self, name, codecs)
@runtime_checkable
class SessionComponentsExportable(Protocol):
"""Type hint for extension bundles installable via ``with_extensions``.
Runtime-checkable, so ``isinstance`` answers whether an object implements
the protocol. Only the presence of the method is checked, which is the same
question :py:meth:`~datafusion.context.SessionContext.with_extensions` asks
before calling it.
Implementations are reusable configuration objects: they must create fresh
components on every call using the context supplied by
:py:meth:`~datafusion.context.SessionContext.with_extensions`, and must not
retain that context or cache the components they bound to it, since the
next call may install onto a different session. They should also avoid
mutating the context they are handed — a registration made during binding
is not rolled back if a later extension fails. See
:ref:`extension_bundles_transaction`.
A bundle that also contributes a query planner implements
:py:class:`SessionPlannerExportable` alongside this protocol.
Args:
ctx: The session the components will run on. Take the task-context
provider off it. Do **not** read its codec chains expecting to find
this call's codecs, including your own: this hook runs before
anything is installed, so ``ctx`` still carries whatever chains the
receiver had. :py:class:`SessionPlannerExportable` is the hook that
sees the completed chains — see :ref:`extension_bundles_two_phases`.
Returns:
The codecs this bundle contributes.
Examples:
>>> from datafusion import SessionExtensionComponents
>>> from datafusion.extensions import SessionComponentsExportable
>>> class MyLibraryExtension:
... def __datafusion_session_components__(self, ctx):
... return SessionExtensionComponents()
>>> isinstance(MyLibraryExtension(), SessionComponentsExportable)
True
>>> isinstance(object(), SessionComponentsExportable)
False
"""
def __datafusion_session_components__( # noqa: D105
self, ctx: SessionContext
) -> SessionExtensionComponents: ...
@runtime_checkable
class SessionPlannerExportable(Protocol):
"""Type hint for extension bundles that contribute a query planner.
A session holds exactly one query planner, so planners compose by nesting
rather than by chaining: each wraps the one before it and delegates to it
for the work it does not handle.
:py:meth:`~datafusion.context.SessionContext.with_extensions` runs this
hook once per bundle that implements it, **in argument order**, handing
each the planner built so far. Returning a planner that wraps ``fallback``
puts this bundle *outside* the previous one, so the last bundle listed ends
up outermost and is consulted first.
The hook runs after every codec from every bundle is installed, and ``ctx``
is the context carrying those final chains. That ordering is the point: a
planner captured here sees the complete codec set, so a nested planner is
not left encoding through a chain that a later bundle has grown. See
:ref:`extension_bundles_two_phases`.
**Return ``None`` to contribute no planner**, leaving ``fallback`` in
place. That is the no-op, and it is not the same as returning ``fallback``:
the capsule the first bundle receives wraps the session's planner for
export, so handing it back installs that planner as a foreign one and every
later plan crosses an FFI boundary that was not there before. A bundle that
decides at runtime it has nothing to contribute returns ``None``.
Ignoring ``fallback`` and returning a planner that does not delegate to it
is legal and means "replace" — but it discards every planner listed before
this one, including any the session already had.
Args:
ctx: The session the planner will run on, carrying the final codec
chains.
fallback: The planner built so far, as a ``PyCapsule``. For the first
bundle this is the session's existing planner, which is the
DataFusion default unless one was installed earlier.
Returns:
A planner wrapping ``fallback``, or ``None`` to contribute none.
Examples:
A real library returns its own planner wrapping ``fallback``, e.g.
``my_library.Planner(fallback=fallback)``. The two degenerate cases are
worth contrasting, because both plan queries successfully and only one
of them is the no-op:
>>> from datafusion import SessionContext
>>> from datafusion.extensions import SessionPlannerExportable
>>> class Contributes:
... def __datafusion_session_planner__(self, ctx, fallback):
... return None # the no-op: session keeps its own planner
>>> class Replaces:
... def __datafusion_session_planner__(self, ctx, fallback):
... return fallback # installs it as a *foreign* planner
>>> for bundle in (Contributes(), Replaces()):
... ctx = SessionContext().with_extensions(bundle)
... ctx.sql("SELECT 1 AS n").collect()[0].column(0).to_pylist()
[1]
[1]
>>> isinstance(Contributes(), SessionPlannerExportable)
True
>>> isinstance(object(), SessionPlannerExportable)
False
"""
def __datafusion_session_planner__( # noqa: D105
self, ctx: SessionContext, fallback: _PyCapsule
) -> QueryPlannerExportable | _PyCapsule | None: ...