| # 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. | |
| from __future__ import annotations | |
| import pyarrow as pa | |
| import pytest | |
| from datafusion import Expr, LogicalPlan, SessionContext, col, udf | |
| from datafusion_ffi_example import ( | |
| MyLogicalExtensionCodec, | |
| MyTableProvider, | |
| NameOnlyFunction, | |
| NameOnlyUdfCodec, | |
| ) | |
| def _double_udf(): | |
| return udf( | |
| lambda arr: pa.array([(v.as_py() or 0) * 2 for v in arr]), | |
| [pa.int64()], | |
| pa.int64(), | |
| volatility="immutable", | |
| name="double", | |
| ) | |
| def _encode_provider_plan(token: str) -> tuple[bytes, MyLogicalExtensionCodec]: | |
| """Serialize a plan over this library's table provider using a codec | |
| that stamps `token` on the encoded provider. | |
| Returns the blob and the codec, so callers can assert on its call | |
| counters. The token is chosen per test so a second codec installed | |
| later is provably unable to claim these bytes. | |
| The codec is installed under ``token`` as its id as well, so a | |
| caller can reinstall the same instance elsewhere and have the tag on | |
| these bytes resolve. Identity would otherwise be derived from the | |
| class, which every instance shares. | |
| """ | |
| codec = MyLogicalExtensionCodec(provider_prefix=token) | |
| ctx = SessionContext().with_logical_extension_codec(codec, codec_id=token) | |
| ctx.register_table("numbers", MyTableProvider(1, 4, 1)) | |
| blob = ctx.sql('SELECT "A" FROM numbers').logical_plan().to_bytes(ctx) | |
| assert token.encode() in blob | |
| return blob, codec | |
| def _setup_session_with_codec() -> tuple[SessionContext, MyLogicalExtensionCodec]: | |
| """Build a session with the user-supplied logical extension codec | |
| installed. Tests use a FROM-less query so plan serialization does | |
| not pull in `try_encode_table_provider`, which the default codec | |
| leaves unimplemented.""" | |
| base = SessionContext() | |
| codec = MyLogicalExtensionCodec() | |
| ctx = base.with_logical_extension_codec(codec) | |
| return ctx, codec | |
| def test_ffi_logical_codec_install_and_export(): | |
| """Installing a user FFI codec adds it to the session's logical | |
| codec chain; the capsule getter on the session re-exports it.""" | |
| ctx, _codec = _setup_session_with_codec() | |
| capsule = ctx.__datafusion_logical_extension_codec__() | |
| assert capsule is not None | |
| def test_ffi_logical_codec_consulted_on_udf_encode(): | |
| """Serializing through ctx.logical_codec() routes try_encode_udf to | |
| the user-installed FFI codec. | |
| Verifies the dispatch chain | |
| `PyLogicalPlan.to_bytes -> session.logical_codec -> | |
| PythonLogicalCodec -> FFI_LogicalExtensionCodec -> user impl` | |
| is wired correctly. The user codec's atomic counter increments | |
| after a serialization pass, proving every hop forwards. | |
| Does not test any Python-UDF-specific dispatch — PythonLogicalCodec | |
| currently delegates all UDF encoding to its inner codec | |
| unconditionally. Python-vs-other branching lands when in-band | |
| scalar UDF encoding is added. | |
| """ | |
| ctx, codec = _setup_session_with_codec() | |
| df = ctx.sql("SELECT abs(-1) AS x") | |
| plan = df.logical_plan() | |
| before = codec.encode_udf_calls() | |
| _ = plan.to_bytes(ctx) | |
| after = codec.encode_udf_calls() | |
| assert after > before, ( | |
| f"Expected user FFI codec encode_udf to fire, before={before} after={after}" | |
| ) | |
| def test_ffi_logical_codec_roundtrip(): | |
| """A plan referencing an FFI-imported UDF round-trips through the | |
| user-supplied logical codec (encode via codec, decode resolves from | |
| registry — `try_decode_udf` is only consulted when the UDF is not | |
| in the registry, which is the codec-inlined case).""" | |
| ctx, _codec = _setup_session_with_codec() | |
| df = ctx.sql("SELECT abs(-1) AS x") | |
| blob = df.logical_plan().to_bytes(ctx) | |
| restored = LogicalPlan.from_bytes(ctx, blob) | |
| df_round_trip = ctx.create_dataframe_from_logical_plan(restored) | |
| assert df.collect() == df_round_trip.collect() | |
| def test_ffi_logical_codec_composes_with_later_install(): | |
| """Codecs compose: installing a second codec appends it to the | |
| session's codec chain instead of replacing the first. The second | |
| codec here (a default-backed codec exported from a fresh session) | |
| cannot encode this library's table provider, so the first codec | |
| still claims it. Under replace semantics this test fails with | |
| `LogicalExtensionCodec is not provided`. | |
| Both directions are counted. Asserting only the round-trip result | |
| would pass if decoding resolved the table some other way; the | |
| decode count pins that the bytes went back to the codec that wrote | |
| them.""" | |
| ctx, codec = _setup_session_with_codec() | |
| ctx = ctx.with_logical_extension_codec( | |
| SessionContext().__datafusion_logical_extension_codec__() | |
| ) | |
| ctx.register_table("numbers", MyTableProvider(1, 4, 1)) | |
| df = ctx.sql('SELECT "A" FROM numbers') | |
| plan = df.logical_plan() | |
| encode_before = codec.table_provider_encode_calls() | |
| decode_before = codec.table_provider_decode_calls() | |
| blob = plan.to_bytes(ctx) | |
| assert codec.table_provider_encode_calls() > encode_before | |
| restored = LogicalPlan.from_bytes(ctx, blob) | |
| assert codec.table_provider_decode_calls() > decode_before | |
| df_round_trip = ctx.create_dataframe_from_logical_plan(restored) | |
| assert df.collect() == df_round_trip.collect() | |
| def test_first_installed_codec_encodes(): | |
| """Encoding walks the chain in install order, so the earliest | |
| installed codec that can claim an object gets it. | |
| Both orders run in one test on purpose. Asserting a single order | |
| would also pass under replace semantics, where the second install | |
| simply discards the first codec; swapping the order and getting the | |
| other token proves the losing codec was still installed and merely | |
| lost the race. | |
| The two instances need explicit ids: identity is otherwise derived | |
| from the class, and these are two instances of one class owning | |
| disjoint slices of the wire format. | |
| """ | |
| for winner, loser in (("TOKENAAA", "TOKENBBB"), ("TOKENBBB", "TOKENAAA")): | |
| winner_codec = MyLogicalExtensionCodec(provider_prefix=winner) | |
| loser_codec = MyLogicalExtensionCodec(provider_prefix=loser) | |
| ctx = SessionContext().with_logical_extension_codec( | |
| winner_codec, codec_id=winner | |
| ) | |
| ctx = ctx.with_logical_extension_codec(loser_codec, codec_id=loser) | |
| ctx.register_table("numbers", MyTableProvider(1, 4, 1)) | |
| blob = ctx.sql('SELECT "A" FROM numbers').logical_plan().to_bytes(ctx) | |
| assert winner.encode() in blob | |
| assert loser.encode() not in blob | |
| assert winner_codec.table_provider_encode_calls() == 1 | |
| assert loser_codec.table_provider_encode_calls() == 0 | |
| def test_installing_a_codec_cannot_hijack_an_earlier_codecs_objects(): | |
| """Appending is additive: a later install can claim objects nothing | |
| else claimed, but never takes over an object an earlier codec was | |
| already encoding. | |
| This is why install order is append rather than prepend. Under | |
| prepend, adding an unrelated library would silently change how an | |
| existing library's objects encode -- and, once payloads are tagged, | |
| would renumber ids that older payloads already reference. | |
| """ | |
| first = MyLogicalExtensionCodec(provider_prefix="TOKENAAA") | |
| ctx = SessionContext().with_logical_extension_codec(first, codec_id="TOKENAAA") | |
| ctx.register_table("numbers", MyTableProvider(1, 4, 1)) | |
| before = ctx.sql('SELECT "A" FROM numbers').logical_plan().to_bytes(ctx) | |
| later = MyLogicalExtensionCodec(provider_prefix="TOKENBBB") | |
| ctx = ctx.with_logical_extension_codec(later, codec_id="TOKENBBB") | |
| after = ctx.sql('SELECT "A" FROM numbers').logical_plan().to_bytes(ctx) | |
| # Provider tokens are minted per encode, so the payloads differ in the | |
| # token id. What must not change is which codec claimed the provider. | |
| assert b"TOKENAAA" in after | |
| assert b"TOKENBBB" not in after | |
| assert later.table_provider_encode_calls() == 0 | |
| assert len(before) == len(after) | |
| def test_decode_dispatches_to_the_codec_that_encoded(): | |
| """A payload names the codec that wrote it, so decoding consults | |
| exactly that codec and never offers the bytes to any other. | |
| The blob here is written by ``first`` in its own session. Installing | |
| ``second`` alongside it must not put ``second`` anywhere near those | |
| bytes -- under trial-and-error dispatch it would be asked first, and | |
| a codec that decodes structurally similar protobuf would answer. | |
| """ | |
| blob, first = _encode_provider_plan("TOKENAAA") | |
| second = MyLogicalExtensionCodec(provider_prefix="TOKENBBB") | |
| ctx = SessionContext().with_logical_extension_codec(first, codec_id="TOKENAAA") | |
| ctx = ctx.with_logical_extension_codec(second, codec_id="TOKENBBB") | |
| restored = LogicalPlan.from_bytes(ctx, blob) | |
| assert ctx.create_dataframe_from_logical_plan(restored).collect() | |
| assert first.table_provider_decode_calls() == 1 | |
| assert second.table_provider_decode_calls() == 0 | |
| def test_decode_survives_a_different_install_order(): | |
| """Dispatch keys off codec identity, not chain position, so the | |
| decoding session may install the same codecs in any order. | |
| This is the case positional dispatch cannot handle: the encoding | |
| session has the owning codec at index 0 and the decoding session has | |
| it at index 1. Keying on position would hand the payload to whatever | |
| sits at index 0 in the decoder -- silently, and with a plausible | |
| result. | |
| """ | |
| owner = MyLogicalExtensionCodec(provider_prefix="TOKENAAA") | |
| other = MyLogicalExtensionCodec(provider_prefix="TOKENBBB") | |
| encoder = SessionContext().with_logical_extension_codec(owner, codec_id="TOKENAAA") | |
| encoder = encoder.with_logical_extension_codec(other, codec_id="TOKENBBB") | |
| encoder.register_table("numbers", MyTableProvider(1, 4, 1)) | |
| blob = encoder.sql('SELECT "A" FROM numbers').logical_plan().to_bytes(encoder) | |
| # Same codecs, opposite order. | |
| decoder = SessionContext().with_logical_extension_codec(other, codec_id="TOKENBBB") | |
| decoder = decoder.with_logical_extension_codec(owner, codec_id="TOKENAAA") | |
| restored = LogicalPlan.from_bytes(decoder, blob) | |
| assert decoder.create_dataframe_from_logical_plan(restored).collect() | |
| assert other.table_provider_decode_calls() == 0 | |
| def test_decode_names_the_codec_that_is_not_installed(): | |
| """When the owning codec is absent the error names it and lists what | |
| is installed, instead of reporting DataFusion's generic "not | |
| provided" from whichever codec was tried last.""" | |
| blob, _owner = _encode_provider_plan("TOKENBBB") | |
| ctx = SessionContext().with_logical_extension_codec( | |
| MyLogicalExtensionCodec(provider_prefix="TOKENCCC"), codec_id="lib_c.Codec" | |
| ) | |
| with pytest.raises(Exception, match="TOKENBBB") as excinfo: | |
| LogicalPlan.from_bytes(ctx, blob) | |
| message = str(excinfo.value) | |
| # Names the codec the payload belongs to, and what is actually here. | |
| assert "not installed on this session" in message | |
| assert "lib_c.Codec" in message | |
| def test_a_codec_id_defaults_to_its_class_module_and_qualname(): | |
| """A codec that declares no identity is named by its class. | |
| That string goes on the wire in front of every payload the codec | |
| writes, so it is a compatibility surface: renaming the class or moving | |
| the module changes it, and plans stored by an earlier release stop | |
| decoding. A library that needs to rename declares | |
| ``__datafusion_codec_id__`` instead, which the next test covers. | |
| Pinned twice on purpose -- once against the literal, so a rename has to | |
| come here and be acknowledged, and once against the rule, so the | |
| literal cannot drift into something the code no longer derives. | |
| """ | |
| ctx = SessionContext().with_logical_extension_codec(MyLogicalExtensionCodec()) | |
| assert ctx.logical_extension_codec_ids() == [ | |
| "datafusion_ffi_example.MyLogicalExtensionCodec" | |
| ] | |
| assert ctx.logical_extension_codec_ids() == [ | |
| f"{MyLogicalExtensionCodec.__module__}.{MyLogicalExtensionCodec.__qualname__}" | |
| ] | |
| class _CodecUnderItsOldName: | |
| """A library codec that pins its identity, so the class can be renamed. | |
| Delegates the capsule getter to a real FFI codec. ``session`` is | |
| forwarded, because that argument is how the underlying library reaches | |
| the session the codec is being installed on. | |
| """ | |
| __datafusion_codec_id__ = "pinned.example.Codec" | |
| def __init__(self, inner: MyLogicalExtensionCodec) -> None: | |
| self._inner = inner | |
| def __datafusion_logical_extension_codec__(self, session: object = None) -> object: | |
| return self._inner.__datafusion_logical_extension_codec__(session) | |
| class _CodecUnderItsNewName(_CodecUnderItsOldName): | |
| """The same codec after a rename. Same pinned id, different class.""" | |
| def test_a_pinned_codec_id_survives_a_class_rename(): | |
| """``__datafusion_codec_id__`` decouples identity from the class name, | |
| which is the reason to declare one. | |
| A plan encoded by the codec under its old name decodes on a session | |
| that only knows the new name. Under the class-derived default the two | |
| would be different ids and the payload would be undecodable. | |
| """ | |
| assert _CodecUnderItsOldName.__qualname__ != _CodecUnderItsNewName.__qualname__ | |
| old = MyLogicalExtensionCodec(provider_prefix="TOKENAAA") | |
| encoder = SessionContext().with_logical_extension_codec(_CodecUnderItsOldName(old)) | |
| assert encoder.logical_extension_codec_ids() == ["pinned.example.Codec"] | |
| encoder.register_table("numbers", MyTableProvider(1, 4, 1)) | |
| blob = encoder.sql('SELECT "A" FROM numbers').logical_plan().to_bytes(encoder) | |
| # The pinned id, not the class name, is what the payload carries. | |
| assert b"pinned.example.Codec" in blob | |
| assert b"_CodecUnderItsOldName" not in blob | |
| new = MyLogicalExtensionCodec(provider_prefix="TOKENAAA") | |
| decoder = SessionContext().with_logical_extension_codec(_CodecUnderItsNewName(new)) | |
| assert decoder.logical_extension_codec_ids() == ["pinned.example.Codec"] | |
| restored = LogicalPlan.from_bytes(decoder, blob) | |
| assert decoder.create_dataframe_from_logical_plan(restored).collect() | |
| assert new.table_provider_decode_calls() == 1 | |
| def test_installing_two_codecs_under_one_id_is_rejected(): | |
| """Identity is derived from the class, so installing two instances of | |
| one class collides. Rejecting at install time is the point: two | |
| codecs sharing an id are indistinguishable when a payload is decoded, | |
| and only the caller knows whether they write the same wire format.""" | |
| ctx = SessionContext().with_logical_extension_codec(MyLogicalExtensionCodec()) | |
| with pytest.raises(ValueError, match="already installed"): | |
| ctx.with_logical_extension_codec(MyLogicalExtensionCodec()) | |
| def _encode_through_a_bare_capsule(token: str) -> tuple[bytes, list[str]]: | |
| """Serialize a provider plan through a codec installed as a bare | |
| capsule, which is the case with no derivable identity. | |
| Returns the blob and the encoding session's codec ids, so a caller | |
| can compare them against another session's. | |
| """ | |
| exporter = SessionContext().with_logical_extension_codec( | |
| MyLogicalExtensionCodec(provider_prefix=token) | |
| ) | |
| encoder = SessionContext().with_logical_extension_codec( | |
| exporter.__datafusion_logical_extension_codec__() | |
| ) | |
| encoder.register_table("numbers", MyTableProvider(1, 4, 1)) | |
| blob = encoder.sql('SELECT "A" FROM numbers').logical_plan().to_bytes(encoder) | |
| return blob, encoder.logical_extension_codec_ids() | |
| def test_bare_capsule_codec_is_session_local(): | |
| """A bare PyCapsule exposes nothing stable to derive an identity | |
| from -- every capsule reports the same type -- so it is tagged with a | |
| session-local id. A plan it encodes fails on an unrelated session | |
| with an error naming the fix, rather than being decoded by whichever | |
| codec happens to sit at the same position.""" | |
| blob, _ids = _encode_through_a_bare_capsule("TOKENAAA") | |
| with pytest.raises(Exception, match="bare PyCapsule") as excinfo: | |
| LogicalPlan.from_bytes(SessionContext(), blob) | |
| assert "codec_id" in str(excinfo.value) | |
| def test_a_bare_capsule_codec_id_is_not_re_mintable_by_another_session(): | |
| """The id given to a capsule-installed codec must be one no other | |
| session can arrive at. | |
| Both sessions here install exactly one bare capsule, so any identity | |
| drawn from a namespace both sessions number the same way -- a | |
| counter, a position in the chain -- collides, and the payload is | |
| handed to the other library's codec. That failure is quiet: a codec | |
| offered bytes it does not recognise falls through to its own inner | |
| default codec, so the error names neither codec and no counter moves. | |
| Asserting on the message is what separates the two schemes. | |
| The empty-chain case in the test above passes under either scheme, | |
| because a lookup in an empty chain misses whatever the id is. | |
| """ | |
| blob, encoder_ids = _encode_through_a_bare_capsule("TOKENAAA") | |
| # An unrelated session, also holding exactly one bare capsule. | |
| other = SessionContext().with_logical_extension_codec( | |
| MyLogicalExtensionCodec(provider_prefix="TOKENBBB") | |
| ) | |
| decoder = SessionContext().with_logical_extension_codec( | |
| other.__datafusion_logical_extension_codec__() | |
| ) | |
| decoder_ids = decoder.logical_extension_codec_ids() | |
| assert len(encoder_ids) == len(decoder_ids) == 1 | |
| assert encoder_ids != decoder_ids | |
| with pytest.raises(Exception, match="bare PyCapsule") as excinfo: | |
| LogicalPlan.from_bytes(decoder, blob) | |
| assert "codec_id" in str(excinfo.value) | |
| def test_installing_a_session_as_a_codec_uses_a_per_session_id(): | |
| """A context installed as a codec is identified by its session, not by | |
| its class. | |
| Every ``SessionContext`` shares one class, so a class-derived id would | |
| name all of them: two contexts could not coexist on one target, and a | |
| payload written through one would resolve to the other on decode. The | |
| sources are held in locals because the imported codecs resolve their | |
| task context against them. | |
| """ | |
| src_a = SessionContext().with_logical_extension_codec( | |
| MyLogicalExtensionCodec(provider_prefix="TOKENAAA") | |
| ) | |
| src_b = SessionContext().with_logical_extension_codec( | |
| MyLogicalExtensionCodec(provider_prefix="TOKENBBB") | |
| ) | |
| assert src_a.__datafusion_codec_id__ != src_b.__datafusion_codec_id__ | |
| # Both sources compose onto one session, which a shared id would refuse. | |
| both = SessionContext().with_logical_extension_codec(src_a) | |
| both = both.with_logical_extension_codec(src_b) | |
| assert both.logical_extension_codec_ids() == [ | |
| src_a.__datafusion_codec_id__, | |
| src_b.__datafusion_codec_id__, | |
| ] | |
| # A payload written through one source does not resolve to the other. | |
| encoder = SessionContext().with_logical_extension_codec(src_a) | |
| encoder.register_table("numbers", MyTableProvider(1, 4, 1)) | |
| blob = encoder.sql('SELECT "A" FROM numbers').logical_plan().to_bytes(encoder) | |
| decoder = SessionContext().with_logical_extension_codec(src_b) | |
| with pytest.raises(Exception, match="not installed on this session") as excinfo: | |
| LogicalPlan.from_bytes(decoder, blob) | |
| assert src_a.__datafusion_codec_id__ in str(excinfo.value) | |
| def test_a_derived_handle_reports_its_session_id(): | |
| """Handles derived from one session share its id, so only one of them | |
| can be installed on a given target. Their payloads would be | |
| indistinguishable on decode, so refusing is the right answer.""" | |
| base = SessionContext() | |
| derived = base.with_python_udf_inlining(enabled=False) | |
| assert derived.__datafusion_codec_id__ == base.__datafusion_codec_id__ | |
| target = SessionContext().with_logical_extension_codec(base) | |
| with pytest.raises(ValueError, match="already installed"): | |
| target.with_logical_extension_codec(derived) | |
| def test_name_only_codec_round_trips_without_a_payload(): | |
| """A codec may own functions that need no payload: the name is the | |
| whole encoding. ``try_encode_udf`` writes nothing, and the decoder | |
| rebuilds the function from the name with no registry entry. | |
| DataFusion supports this directly -- an empty ``fun_definition`` | |
| sends the decoder to the registry first and the codec second. This | |
| test pins that arm from the Python side, because it is the one path | |
| where a payload is still offered to every installed codec: there are | |
| no bytes, so there is no identity to dispatch on. | |
| It is also the guard against a plausible "improvement". Wrapping | |
| every chained encode in the identity envelope would make this | |
| payload non-empty, which sets ``fun_definition`` and permanently | |
| skips the registry lookup -- breaking both this codec and ordinary | |
| by-name round trips, with nothing else in the suite noticing. | |
| """ | |
| codec = NameOnlyUdfCodec() | |
| name = NameOnlyUdfCodec.function_name() | |
| # FROM-less, so serialization never reaches try_encode_table_provider -- | |
| # this codec owns functions, not providers. | |
| encoder = SessionContext().with_logical_extension_codec(codec) | |
| encoder.register_udf(udf(NameOnlyFunction())) | |
| blob = encoder.sql(f"SELECT {name}(1) AS x").logical_plan().to_bytes(encoder) | |
| # The name is the entire encoding, so the codec contributed no bytes | |
| # and the payload carries no identity envelope for it. | |
| assert codec.encode_udf_calls() > 0 | |
| assert b"DFPYCHN" not in blob | |
| # A fresh session that never registered the function: only the codec | |
| # can supply it, and only from the name. | |
| decoder = SessionContext().with_logical_extension_codec(codec) | |
| restored = LogicalPlan.from_bytes(decoder, blob) | |
| assert codec.decode_udf_calls() > 0 | |
| assert decoder.create_dataframe_from_logical_plan(restored).collect() | |
| def test_default_only_session_writes_no_envelope(): | |
| """A session with no extension codecs installed produces the same | |
| bytes as a build without codec chaining: the terminal codec writes | |
| unframed, so the envelope only appears once a codec is installed. | |
| Keeps the wire break scoped to sessions that actually compose.""" | |
| ctx = SessionContext() | |
| blob = ctx.sql("SELECT abs(-1) AS x").logical_plan().to_bytes(ctx) | |
| assert b"DFPYCHN" not in blob | |
| def test_udf_inlining_setting_survives_codec_install(): | |
| """Installing an extension codec must not silently re-enable inline | |
| Python UDF encoding on a session that opted out. Regression guard in | |
| both directions: the encoder still emits the by-name form, and the | |
| decoder still refuses an inline payload. | |
| The codec installed here delegates UDF encoding to DataFusion's | |
| default codec. A codec exported from another `SessionContext` would | |
| not work as a probe: that export is itself a Python-aware codec with | |
| inlining enabled, so the strict outer codec would delegate to it and | |
| the inline payload would reappear. | |
| """ | |
| strict = SessionContext().with_python_udf_inlining(enabled=False) | |
| extended = strict.with_logical_extension_codec( | |
| MyLogicalExtensionCodec(provider_prefix="TOKENFFF") | |
| ) | |
| e = _double_udf()(col("a")) | |
| assert b"DFPYUDF" not in e.to_bytes(extended) | |
| inline_blob = e.to_bytes(SessionContext()) | |
| assert b"DFPYUDF" in inline_blob | |
| with pytest.raises(Exception, match="inlining is disabled"): | |
| Expr.from_bytes(inline_blob, ctx=extended) |