From f2c8073634cd1b6b8906769a99a6f1323dadf8c1 Mon Sep 17 00:00:00 2001 From: Hussain Sultan Date: Thu, 30 Jul 2026 09:29:48 -0400 Subject: [PATCH] fix(_xorq): don't let optional plain-ibis interop deactivate the xorq branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `from xorq.common.utils.ibis_utils import from_ibis, map_ibis` sat inside the all-or-nothing HAS_XORQ import block, but xorq's ibis_utils imports the plain ibis-framework stack (ibis, packaging, pyarrow_hotfix) at module scope. In a minimal env (xorq + BSL only) that import raises, the ENTIRE xorq branch silently deactivated — HAS_XORQ read False with xorq fully importable — and the plain-ibis fallback shims ran against xorq objects: to_tagged died at serialization/__init__.py:114 with "AttributeError: 'Table' object has no attribute 'replace'" (observed against xorq 0.3.34 and 0.3.37 alike; this is dependency activation, not an API mismatch — xorq's own node_utils.replace_nodes accepts Expr|Node on both versions). Two changes: - Import from_ibis/map_ibis in their own guard after HAS_XORQ is set; when the plain-ibis stack is absent only those two interop symbols degrade to raising stubs (registry-preserving, same contract as the no-xorq _MapIbisStub). - Harden the no-xorq fallback replace_nodes to normalize Expr → op via to_node, matching xorq's native signature, so it can never crash on an expression even if a future un-gated caller hands it one. Validated: xorq==0.3.34 + BSL, NO plain ibis → HAS_XORQ True, xorq's replace_nodes used, to_tagged works over a deferred_read_csv source; plain-ibis-only env unchanged (fallback accepts op AND expr); 170 xorq/serialization/tagged tests pass. Co-Authored-By: Claude Fable 5 --- src/boring_semantic_layer/_xorq.py | 48 ++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/src/boring_semantic_layer/_xorq.py b/src/boring_semantic_layer/_xorq.py index 20729417..90e724a3 100644 --- a/src/boring_semantic_layer/_xorq.py +++ b/src/boring_semantic_layer/_xorq.py @@ -19,7 +19,6 @@ import xorq.api as api from xorq.api import selectors from xorq.common.utils.graph_utils import to_node - from xorq.common.utils.ibis_utils import from_ibis, map_ibis from xorq.common.utils.node_utils import replace_nodes, walk_nodes from xorq.expr.builders import TagHandler from xorq.expr.relations import CachedNode, Read, RemoteTable, Tag @@ -61,6 +60,48 @@ HAS_XORQ = True + # Plain-ibis interop (from_ibis / map_ibis) is OPTIONAL: xorq's + # `ibis_utils` module imports the plain ibis-framework stack (`ibis`, + # `packaging`, `pyarrow_hotfix`) at module scope. When that stack is + # absent — a minimal env with only xorq + BSL installed — the import + # fails, and before this guard existed it took the ENTIRE xorq branch + # down with it: HAS_XORQ read False with xorq fully importable, the + # plain-ibis fallback shims ran against xorq objects, and `to_tagged` + # died with "'Table' object has no attribute 'replace'". Only the two + # interop symbols may degrade when plain ibis is missing. + try: + from xorq.common.utils.ibis_utils import from_ibis, map_ibis + except ImportError: + + def from_ibis(table): + raise ImportError( + "from_ibis requires the plain ibis-framework stack; " + "install with: pip install ibis-framework packaging pyarrow_hotfix" + ) + + class _MapIbisXorqStub: + """Registry-only stand-in (same contract as the no-xorq stub): + lets _patch_xorq_sortkey_compat() register handlers; calling it + without plain ibis installed is an error.""" + + def __init__(self): + self.registry = {} + + def register(self, type_): + def decorator(fn): + self.registry[type_] = fn + return fn + + return decorator + + def __call__(self, *args, **kwargs): + raise ImportError( + "map_ibis requires the plain ibis-framework stack; " + "install with: pip install ibis-framework packaging pyarrow_hotfix" + ) + + map_ibis = _MapIbisXorqStub() + except ImportError: import ibis from ibis import selectors @@ -156,8 +197,11 @@ def replace_nodes(replacer, node): The xorq replacer signature is ``(node, kwargs) -> node``; ibis passes ``None`` for kwargs when no children changed, which we normalise to - ``{}`` to match xorq's contract. + ``{}`` to match xorq's contract. Accepts an expression or a node — + ``.replace`` lives on ``Node``, so an ``Expr`` is normalised first + (xorq's native ``replace_nodes`` accepts both; this shim must too). """ + node = to_node(node) return node.replace(lambda n, kwargs: replacer(n, kwargs if kwargs is not None else {})) def walk_nodes(*args, **kwargs):