diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml index 8cfec413..804e2f5a 100644 --- a/.github/workflows/ci-release.yml +++ b/.github/workflows/ci-release.yml @@ -19,9 +19,43 @@ concurrency: cancel-in-progress: ${{ github.event_name != 'release' }} jobs: - # Run tests, examples, and build docs + # Run the test suite with and without the xorq extra. The no-xorq leg + # verifies xorq is truly optional (its tests skip, not error). test: - name: Test & Build + name: Test (${{ matrix.name }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - name: full + sync: uv sync --all-extras + - name: no-xorq + sync: uv sync --all-extras --no-extra xorq --no-extra examples + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + + - name: Install dependencies (${{ matrix.name }}) + run: ${{ matrix.sync }} + + - name: Verify import works + run: uv run python -c "import boring_semantic_layer; print('import OK')" + + - name: Run tests + run: uv run pytest + + # Build examples, docs, and skills (require the full env incl. Node). + build: + name: Build (examples + docs + skills) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -47,8 +81,14 @@ jobs: uv sync --all-extras cd docs/web && npm ci - - name: Run all checks (tests + examples + docs build) - run: make check + - name: Run examples + run: make examples + + - name: Build docs + run: make docs-build + + - name: Check skills are up to date + run: make skills-check - name: Upload docs artifact if: github.event_name == 'release' @@ -60,7 +100,7 @@ jobs: deploy-docs: name: Deploy Docs if: github.event_name == 'release' - needs: test + needs: [test, build] runs-on: ubuntu-latest environment: name: github-pages @@ -74,7 +114,7 @@ jobs: release-pypi: name: Release to PyPI if: github.event_name == 'release' - needs: test + needs: [test, build] runs-on: ubuntu-latest environment: name: release diff --git a/pyproject.toml b/pyproject.toml index df2965ee..a081015a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,6 @@ dependencies = [ "pyyaml>=6.0", "returns>=0.26.0", "toolz>=1.0.0", - "xorq>=0.3.25", ] urls = { Homepage = "https://github.com/boringdata/boring-semantic-layer/tree/main" } license = "MIT" @@ -26,7 +25,8 @@ bsl = "boring_semantic_layer.agents.cli:main" bsl = "boring_semantic_layer.serialization.tag_handler:bsl_tag_handler" [project.optional-dependencies] -examples = ["xorq[duckdb]>=0.3.4", "xorq", "duckdb<1.4"] +xorq = ["xorq>=0.3.25"] +examples = ["xorq[duckdb]>=0.3.25", "duckdb<1.4"] # Visualization backends viz-altair = ["altair>=5.0.0", "vl-convert-python>=1.0.0"] @@ -51,11 +51,22 @@ server = [ "uvicorn[standard]>=0.30.0", ] +# Baseline test environment for the no-xorq CI leg: pulled in via +# `uv sync --all-extras` so the suite has a duckdb backend (and pyarrow) +# without depending on xorq or the examples extra. +test-core = [ + "ibis-framework[duckdb]>=11.0.0", + "pytest", + "pandas>=2.3.0", + "pytest-asyncio", +] + dev = [ - "boring-semantic-layer[agent,mcp,server,examples,viz-altair,viz-plotly,viz-plotext]", + # LLM provider clients needed to test agent backends (not user-facing extras) "openai>=1.0.0", "langchain-openai>=0.3.0", "langchain-anthropic>=0.3.0", + # Developer tooling "ruff>=0.6.7", "pre-commit>=4.2.0", "pytest", diff --git a/requirements-dev.txt b/requirements-dev.txt index e9df4081..fff7a143 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,6 +4,8 @@ absl-py==2.3.1 # via malloy altair==5.5.0 # via boring-semantic-layer +annotated-doc==0.0.4 + # via fastapi annotated-types==0.7.0 # via pydantic anthropic==0.75.0 @@ -16,8 +18,11 @@ anyio==4.11.0 # openai # sse-starlette # starlette + # watchfiles asn1crypto==1.5.1 # via snowflake-connector-python +asttokens==3.0.1 + # via stack-data atpublic==6.0.2 # via # ibis-framework @@ -70,10 +75,11 @@ choreographer==1.2.0 # via kaleido cityhash==0.4.10 ; python_full_version < '4' # via xorq -click==8.3.0 ; python_full_version < '4' or sys_platform != 'emscripten' +click==8.3.0 # via # dask # uvicorn + # xorq cloudpickle==3.1.2 # via # dask @@ -81,8 +87,10 @@ cloudpickle==3.1.2 colorama==0.4.6 ; sys_platform == 'win32' # via # click + # ipython # pytest # tqdm + # uvicorn cryptography==46.0.3 # via # authlib @@ -95,6 +103,8 @@ cyclopts==4.2.1 # via fastmcp dask==2025.1.0 ; python_full_version < '4' # via xorq +decorator==5.2.1 + # via ipython diskcache==5.6.3 # via py-key-value-aio distlib==0.4.0 @@ -114,6 +124,7 @@ docutils==0.22.2 duckdb==1.3.2 # via # boring-semantic-layer + # ibis-framework # malloy # xorq email-validator==2.3.0 @@ -124,7 +135,12 @@ exceptiongroup==1.3.0 # via # anyio # fastmcp + # ipython # pytest +executing==2.2.1 + # via stack-data +fastapi==0.135.3 + # via boring-semantic-layer fastjsonschema==2.21.2 # via nbformat fastmcp==2.13.0.2 @@ -133,6 +149,7 @@ filelock==3.20.0 # via # snowflake-connector-python # virtualenv + # xorq fsspec==2025.10.0 ; python_full_version < '4' # via dask gast==0.6.0 ; sys_platform == 'darwin' @@ -141,6 +158,12 @@ gast==0.6.0 ; sys_platform == 'darwin' # pythran geoarrow-types==0.3.0 ; python_full_version < '4' # via xorq +git-annex==10.20260316 + # via xorq +gitdb==4.0.12 + # via gitpython +gitpython==3.1.46 + # via xorq google-api-core==2.28.1 # via # google-cloud-bigquery @@ -177,6 +200,8 @@ h11==0.16.0 # uvicorn httpcore==1.0.9 # via httpx +httptools==0.7.1 + # via uvicorn httpx==0.28.1 # via # anthropic @@ -205,12 +230,18 @@ importlib-metadata==8.7.0 # opentelemetry-api iniconfig==2.3.0 # via pytest +ipython==8.38.0 ; python_full_version < '3.11' +ipython==9.10.0 ; python_full_version >= '3.11' +ipython-pygments-lexers==1.1.1 ; python_full_version >= '3.11' + # via ipython jaraco-classes==3.4.0 # via keyring jaraco-context==6.0.1 # via keyring jaraco-functools==4.3.0 # via keyring +jedi==0.19.2 + # via ipython jeepney==0.9.0 ; sys_platform == 'linux' # via # keyring @@ -270,6 +301,8 @@ langgraph-sdk==0.2.10 # via langgraph langsmith==0.4.49 # via langchain-core +linkify-it-py==2.1.0 + # via markdown-it-py locket==1.0.0 ; python_full_version < '4' # via partd logistro==2.0.1 @@ -279,11 +312,18 @@ logistro==2.0.1 malloy==2024.1096 # via boring-semantic-layer markdown-it-py==4.0.0 - # via rich + # via + # mdit-py-plugins + # rich + # textual markupsafe==3.0.3 # via jinja2 +matplotlib-inline==0.2.1 + # via ipython mcp==1.20.0 # via fastmcp +mdit-py-plugins==0.5.0 + # via textual mdurl==0.1.2 # via markdown-it-py more-itertools==10.8.0 @@ -300,10 +340,12 @@ nodeenv==1.9.1 # via pre-commit numpy==2.2.6 ; python_full_version < '3.11' # via + # ibis-framework # pandas # pythran numpy==2.3.4 ; python_full_version >= '3.11' # via + # ibis-framework # pandas # pythran openai==2.8.1 @@ -326,7 +368,9 @@ opentelemetry-exporter-otlp-proto-common==1.38.0 # opentelemetry-exporter-otlp-proto-grpc # opentelemetry-exporter-otlp-proto-http opentelemetry-exporter-otlp-proto-grpc==1.38.0 - # via opentelemetry-exporter-otlp + # via + # opentelemetry-exporter-otlp + # xorq opentelemetry-exporter-otlp-proto-http==1.38.0 # via opentelemetry-exporter-otlp opentelemetry-exporter-prometheus==0.59b0 @@ -357,6 +401,7 @@ packaging==25.0 # boring-semantic-layer # dask # google-cloud-bigquery + # ibis-framework # kaleido # langchain-core # langsmith @@ -366,7 +411,10 @@ packaging==25.0 pandas==2.3.3 # via # boring-semantic-layer + # ibis-framework # xorq +parso==0.8.6 + # via jedi parsy==2.2 # via # ibis-framework @@ -377,11 +425,14 @@ pathable==0.4.4 # via jsonschema-path pathvalidate==3.3.1 # via py-key-value-aio +pexpect==4.9.0 ; sys_platform != 'emscripten' and sys_platform != 'win32' + # via ipython platformdirs==4.5.0 # via # fastmcp # jupyter-core # snowflake-connector-python + # textual # virtualenv plotext==5.3.2 # via boring-semantic-layer @@ -397,6 +448,8 @@ prometheus-client==0.23.1 # via # opentelemetry-exporter-prometheus # xorq +prompt-toolkit==3.0.52 + # via ipython proto-plus==1.26.1 # via google-api-core protobuf==6.33.0 @@ -406,16 +459,25 @@ protobuf==6.33.0 # grpcio-status # opentelemetry-proto # proto-plus +ptyprocess==0.7.0 ; sys_platform != 'emscripten' and sys_platform != 'win32' + # via pexpect +pure-eval==0.2.3 + # via stack-data py-key-value-aio==0.2.8 # via fastmcp py-key-value-shared==0.2.8 # via py-key-value-aio -pyarrow==21.0.0 ; python_full_version < '4' +py-yaml12==0.1.0 + # via xorq +pyarrow==21.0.0 # via + # ibis-framework # xorq # xorq-datafusion -pyarrow-hotfix==0.7 ; python_full_version < '4' - # via xorq +pyarrow-hotfix==0.7 + # via + # ibis-framework + # xorq pyasn1==0.6.1 # via # pyasn1-modules @@ -427,6 +489,7 @@ pycparser==2.23 ; implementation_name != 'PyPy' and platform_python_implementati pydantic==2.12.3 # via # anthropic + # fastapi # fastmcp # langchain # langchain-anthropic @@ -443,8 +506,11 @@ pydantic-settings==2.11.0 # via mcp pygments==2.19.2 # via + # ipython + # ipython-pygments-lexers # pytest # rich + # textual pyjwt==2.10.1 # via # mcp @@ -457,12 +523,9 @@ pytest==8.4.2 # via # boring-semantic-layer # pytest-asyncio - # pytest-mock # pytest-timeout pytest-asyncio==1.2.0 # via boring-semantic-layer -pytest-mock==3.15.1 ; python_full_version < '4' - # via xorq pytest-timeout==2.4.0 # via kaleido python-dateutil==2.9.0.post0 @@ -477,6 +540,7 @@ python-dotenv==1.2.1 # boring-semantic-layer # fastmcp # pydantic-settings + # uvicorn python-multipart==0.0.20 # via mcp pythran==0.18.0 ; sys_platform == 'darwin' @@ -498,7 +562,7 @@ pyyaml==6.0.3 # jsonschema-path # langchain-core # pre-commit - # xorq + # uvicorn referencing==0.36.2 # via # jsonschema @@ -525,7 +589,9 @@ rich==14.2.0 # boring-semantic-layer # cyclopts # fastmcp + # ibis-framework # rich-rst + # textual # xorq rich-rst==1.3.2 # via cyclopts @@ -547,6 +613,8 @@ simplejson==3.20.2 # via choreographer six==1.17.0 # via python-dateutil +smmap==5.0.2 + # via gitdb sniffio==1.3.1 # via # anthropic @@ -562,14 +630,20 @@ sqlglot==25.20.2 # xorq sse-starlette==3.0.3 # via mcp +stack-data==0.6.3 + # via ipython starlette==0.50.0 - # via mcp + # via + # fastapi + # mcp strenum==0.4.15 ; python_full_version < '3.11' # via xorq structlog==25.5.0 ; python_full_version < '4' # via xorq tenacity==9.1.2 # via langchain-core +textual==8.1.0 + # via xorq tiktoken==0.12.0 # via langchain-openai tomli==2.3.0 ; python_full_version < '3.11' @@ -577,7 +651,9 @@ tomli==2.3.0 ; python_full_version < '3.11' # cyclopts # pytest tomlkit==0.13.3 - # via snowflake-connector-python + # via + # snowflake-connector-python + # xorq toolz==1.1.0 # via # boring-semantic-layer @@ -589,7 +665,9 @@ tqdm==4.67.1 # via openai traitlets==5.14.3 # via + # ipython # jupyter-core + # matplotlib-inline # nbformat typing-extensions==4.15.0 # via @@ -599,8 +677,10 @@ typing-extensions==4.15.0 # cryptography # cyclopts # exceptiongroup + # fastapi # grpcio # ibis-framework + # ipython # langchain-core # openai # opentelemetry-api @@ -618,18 +698,22 @@ typing-extensions==4.15.0 # snowflake-connector-python # starlette # structlog + # textual # typing-inspection # uvicorn # virtualenv # xorq typing-inspection==0.4.2 # via + # fastapi # pydantic # pydantic-settings tzdata==2025.2 # via # ibis-framework # pandas +uc-micro-py==2.0.0 + # via linkify-it-py urllib3==2.5.0 # via # boring-semantic-layer @@ -637,17 +721,27 @@ urllib3==2.5.0 # requests uv==0.9.7 # via xorq -uvicorn==0.38.0 ; sys_platform != 'emscripten' - # via mcp +uvicorn==0.38.0 + # via + # boring-semantic-layer + # mcp +uvloop==0.22.1 ; platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32' + # via uvicorn virtualenv==20.35.4 # via pre-commit vl-convert-python==1.8.0 # via boring-semantic-layer +watchfiles==1.1.1 + # via uvicorn +wcwidth==0.6.0 + # via prompt-toolkit websockets==15.0.1 - # via fastmcp -xorq==0.3.5 + # via + # fastmcp + # uvicorn +xorq==0.3.25 # via boring-semantic-layer -xorq-datafusion==0.2.4 +xorq-datafusion==0.2.7 # via xorq xxhash==3.6.0 # via langgraph diff --git a/scripts/demo_bsl_v2.py b/scripts/demo_bsl_v2.py index 4f06949a..f9537a16 100644 --- a/scripts/demo_bsl_v2.py +++ b/scripts/demo_bsl_v2.py @@ -10,17 +10,21 @@ - Rolling-window calculations """ -from pathlib import Path import sys +from pathlib import Path import ibis -import xorq.api as xo if __package__ in {None, ""}: sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) from boring_semantic_layer import to_semantic_table +# BSL's to_untagged() returns xorq-vendored ibis when xorq is installed, and +# those expressions reject a plain ibis.window (LegacyWindowBuilder). Build the +# window from the matching flavor via the shim (plain ibis when xorq is absent). +from boring_semantic_layer._xorq import ibis as xibis + def main(): # Setup in-memory DuckDB @@ -117,7 +121,7 @@ def main(): .with_measures(sum_val=lambda t: t.value.sum()) ) - rolling_window = xo.window(order_by="date", preceding=1, following=1) + rolling_window = xibis.window(order_by="date", preceding=1, following=1) expr4 = ( ts_st.group_by("date") .aggregate("sum_val") diff --git a/src/boring_semantic_layer/_xorq.py b/src/boring_semantic_layer/_xorq.py index 167d714b..20729417 100644 --- a/src/boring_semantic_layer/_xorq.py +++ b/src/boring_semantic_layer/_xorq.py @@ -6,52 +6,172 @@ This shim does NOT replace the plain ``ibis`` package (PyPI ibis-framework). BSL coexists with both flavors: use ``import ibis`` for the plain side, and this module for the ``xorq.vendor.ibis`` side. + +When xorq is not installed, this shim falls back to plain ``ibis-framework`` +equivalents. Xorq-only features (``to_tagged``, ``from_tagged``, tagging, +caching) remain gated behind explicit ``ImportError`` checks in their +respective modules. """ from __future__ import annotations -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 -from xorq.vendor import ibis -from xorq.vendor.ibis import _ -from xorq.vendor.ibis.backends.profiles import Profile -from xorq.vendor.ibis.common.collections import FrozenDict, FrozenOrderedDict -from xorq.vendor.ibis.common.deferred import ( - Attr, - BinaryOperator, - Call, - Deferred, - Item, - Just, - JustUnhashable, - Mapping, - Sequence, - UnaryOperator, - Variable, -) -from xorq.vendor.ibis.common.graph import Graph -from xorq.vendor.ibis.config import Config -from xorq.vendor.ibis.expr import operations, types -from xorq.vendor.ibis.expr.format import fmt, render_fields -from xorq.vendor.ibis.expr.operations import relations -from xorq.vendor.ibis.expr.operations.core import Node -from xorq.vendor.ibis.expr.operations.generic import Literal -from xorq.vendor.ibis.expr.operations.relations import ( - DatabaseTable, - Field, - JoinChain, - JoinReference, -) -from xorq.vendor.ibis.expr.operations.sortkeys import SortKey -from xorq.vendor.ibis.expr.schema import Schema -from xorq.vendor.ibis.expr.types import Expr, Table -from xorq.vendor.ibis.expr.types.generic import Column -from xorq.vendor.ibis.expr.types.groupby import GroupedTable +try: + 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 + from xorq.vendor import ibis + from xorq.vendor.ibis import _ + from xorq.vendor.ibis.backends.profiles import Profile + from xorq.vendor.ibis.common.collections import FrozenDict, FrozenOrderedDict + from xorq.vendor.ibis.common.deferred import ( + Attr, + BinaryOperator, + Call, + Deferred, + Item, + Just, + JustUnhashable, + Mapping, + Sequence, + UnaryOperator, + Variable, + ) + from xorq.vendor.ibis.common.graph import Graph + from xorq.vendor.ibis.config import Config + from xorq.vendor.ibis.expr import operations, types + from xorq.vendor.ibis.expr.format import fmt, render_fields + from xorq.vendor.ibis.expr.operations import relations + from xorq.vendor.ibis.expr.operations.core import Node + from xorq.vendor.ibis.expr.operations.generic import Literal + from xorq.vendor.ibis.expr.operations.relations import ( + DatabaseTable, + Field, + JoinChain, + JoinReference, + ) + from xorq.vendor.ibis.expr.operations.sortkeys import SortKey + from xorq.vendor.ibis.expr.schema import Schema + from xorq.vendor.ibis.expr.types import Expr, Table + from xorq.vendor.ibis.expr.types.generic import Column + from xorq.vendor.ibis.expr.types.groupby import GroupedTable + + HAS_XORQ = True + +except ImportError: + import ibis + from ibis import selectors + from ibis.common.collections import FrozenDict, FrozenOrderedDict + from ibis.common.deferred import ( + Attr, + BinaryOperator, + Call, + Deferred, + Item, + Just, + JustUnhashable, + Mapping, + Sequence, + UnaryOperator, + Variable, + ) + from ibis.common.graph import Graph + from ibis.config import Config + from ibis.expr import operations, types + from ibis.expr.format import fmt, render_fields + from ibis.expr.operations import relations + from ibis.expr.operations.core import Node + from ibis.expr.operations.generic import Literal + from ibis.expr.operations.relations import ( + DatabaseTable, + Field, + JoinChain, + JoinReference, + ) + from ibis.expr.operations.sortkeys import SortKey + from ibis.expr.schema import Schema + from ibis.expr.types import Expr, Table + from ibis.expr.types.generic import Column + from ibis.expr.types.groupby import GroupedTable + + api = ibis + _ = ibis._ + + HAS_XORQ = False + + # Xorq-only symbols: None sentinels so isinstance checks return False and + # call sites that are already gated (to_tagged, from_tagged, tag_handler) + # will fail with a clear AttributeError rather than a confusing NameError. + TagHandler = None + CachedNode = None + Read = None + RemoteTable = None + Tag = None + Profile = None + + def from_ibis(table): + """Identity: without xorq, plain-ibis tables stay as plain ibis.""" + return table + + class _MapIbisStub: + """Stub for xorq's map_ibis singledispatch mechanism. + + Allows _patch_xorq_sortkey_compat() to register a handler without + error; the handler body never runs because map_ibis() is only called + by xorq internals during xorq-table conversion, which doesn't happen + when xorq is absent. + """ + + 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( + "xorq is required for map_ibis; " + "install with: pip install 'boring-semantic-layer[xorq]'" + ) + + map_ibis = _MapIbisStub() + + def to_node(maybe_expr): + """Convert an expression or node to a Node.""" + if isinstance(maybe_expr, Node): + return maybe_expr + if isinstance(maybe_expr, Expr): + return maybe_expr.op() + raise ValueError(f"Cannot convert {type(maybe_expr).__name__!r} to an expression node") + + def replace_nodes(replacer, node): + """Replace nodes in the tree via ibis ``Node.replace``. + + 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. + """ + return node.replace(lambda n, kwargs: replacer(n, kwargs if kwargs is not None else {})) + + def walk_nodes(*args, **kwargs): + # Defined so the shim's symbol surface stays symmetric: every name the + # xorq branch exports is importable from this branch too, so the + # function-local ``from ._xorq import walk_nodes`` at xorq-gated call + # sites resolves. Those call sites all bail on ``HAS_XORQ`` first, so the + # body never runs without xorq; the raise is a clear signal if a future + # un-gated caller ever reaches it. + raise ImportError( + "xorq is required for walk_nodes; " + "install with: pip install 'boring-semantic-layer[xorq]'" + ) + __all__ = [ "Attr", @@ -68,6 +188,7 @@ "FrozenOrderedDict", "Graph", "GroupedTable", + "HAS_XORQ", "Item", "JoinChain", "JoinReference", diff --git a/src/boring_semantic_layer/chart/tests/test_chart.py b/src/boring_semantic_layer/chart/tests/test_chart.py index e1d312de..a5fcc182 100644 --- a/src/boring_semantic_layer/chart/tests/test_chart.py +++ b/src/boring_semantic_layer/chart/tests/test_chart.py @@ -3,10 +3,14 @@ import ibis import pandas as pd import pytest -import xorq.api as xo from boring_semantic_layer import to_semantic_table +# BSL's to_untagged() returns xorq-vendored ibis when xorq is installed, and +# those expressions reject a plain ibis.window (LegacyWindowBuilder). Build the +# window from the matching flavor via the shim (plain ibis when xorq is absent). +from boring_semantic_layer._xorq import ibis as xibis + @pytest.fixture(scope="module") def con(): @@ -387,7 +391,7 @@ def test_chart_with_dynamic_dimension_and_rolling_window(self, flights_model): .order_by("flight_week") .mutate( rolling_avg=lambda t: t.flight_count.mean().over( - xo.window(rows=(-2, 0), order_by="flight_week") + xibis.window(rows=(-2, 0), order_by="flight_week") ) ) ) diff --git a/src/boring_semantic_layer/ops.py b/src/boring_semantic_layer/ops.py index 0835f590..86931b5e 100644 --- a/src/boring_semantic_layer/ops.py +++ b/src/boring_semantic_layer/ops.py @@ -316,6 +316,12 @@ def _rebind_to_canonical_backend(expr): No-op on plain ibis expressions (not xorq-vendored). """ + from ._xorq import HAS_XORQ + + # Without xorq there is only one backend, so there is nothing to rebind. + if not HAS_XORQ: + return expr + try: from ._xorq import relations as xorq_rel, walk_nodes except Exception: @@ -4319,6 +4325,13 @@ def _rebind_join_backends(left_tbl, right_tbl): fall back to returning the inputs unchanged so ibis executes the join natively. Rebinding is only needed for xorq-vendored backends. """ + from ._xorq import HAS_XORQ + + # Without xorq, from_ibis() is an identity, so both sides already share + # their backends — nothing to rebind (see _rebind_to_canonical_backend). + if not HAS_XORQ: + return left_tbl, right_tbl + try: from ._xorq import relations as xorq_rel, walk_nodes except ImportError: @@ -4854,6 +4867,12 @@ def _dimension_only_source_table( tbl_cols = frozenset(tbl.columns) | frozenset(root_dims) for flt in filters: fn = _unwrap(flt) if hasattr(flt, "unwrap") else flt + # Dict/string filters resolve deferred through the + # backend; their columns can't be statically + # introspected, so disable the shortcut rather than + # risk a wrong source table. See query.Filter.to_callable. + if getattr(fn, "__bsl_deferred_resolution__", False): + return None extraction = _extract_columns_from_callable(fn, tbl) if extraction.extraction_failed: return None # Can't determine — bail out diff --git a/src/boring_semantic_layer/profile.py b/src/boring_semantic_layer/profile.py index 5d65c709..b48cfb8f 100644 --- a/src/boring_semantic_layer/profile.py +++ b/src/boring_semantic_layer/profile.py @@ -1,10 +1,12 @@ from __future__ import annotations import os +import re from pathlib import Path from ibis import BaseBackend -from ._xorq import Profile as XorqProfile + +from ._xorq import HAS_XORQ, Profile as XorqProfile from .utils import read_yaml_file @@ -83,7 +85,7 @@ def _search_profile(name: str, search_locations: list[str]) -> BaseBackend: return _load_from_file(bsl_profile, name) if location == "local" and local_profile.exists(): return _load_from_file(local_profile, name) - if location == "xorq_dir": + if location == "xorq_dir" and HAS_XORQ: try: return XorqProfile.load(name).get_con() except Exception: @@ -112,12 +114,50 @@ def _load_from_file(yaml_file: Path, profile_name: str | None = None) -> BaseBac return _create_connection_from_config(config) +_ENV_VAR_PATTERN = re.compile(r"\$\{(\w+)\}|\$(\w+)") + + +def _expand_env_vars(value: str) -> str: + """Expand ``${VAR}``/``$VAR`` references, raising on undefined variables. + + Unlike ``os.path.expandvars``, which silently leaves unresolved + references in place (so ``database: ${MISSING}`` would create a file + literally named ``${MISSING}``), this mirrors xorq's strict behavior and + fails loudly. Keeps env-var handling consistent whether or not xorq is + installed. + """ + + def _replace(match: re.Match) -> str: + name = match.group(1) or match.group(2) + try: + return os.environ[name] + except KeyError: + raise ProfileError( + f"Environment variable '{name}' referenced in profile is not set." + ) from None + + return _ENV_VAR_PATTERN.sub(_replace, value) + + +def _connect_plain_ibis(ibis, config: dict, conn_type: str) -> BaseBackend: + """Connect using plain ibis..connect() with manual env-var expansion.""" + connect_fn = getattr(ibis, conn_type, None) + if connect_fn is None or not callable(getattr(connect_fn, "connect", None)): + raise ProfileError(f"Unknown backend type: '{conn_type}'") + connect_kwargs = {k: v for k, v in config.items() if k != "type"} + connect_kwargs = { + k: _expand_env_vars(v) if isinstance(v, str) else v + for k, v in connect_kwargs.items() + } + return connect_fn.connect(**connect_kwargs) + + def _create_connection_from_config(config: dict) -> BaseBackend: """Create database connection from config dict with 'type' field. - Tries xorq first (which handles env var substitution automatically), - then falls back to plain ``ibis..connect()`` for backends - not registered in xorq (e.g. Databricks). + When xorq is installed, tries xorq first (handles env-var substitution + automatically) and falls back to plain ibis for unsupported backends. + When xorq is absent, uses plain ``ibis..connect()`` directly. """ import ibis @@ -128,23 +168,16 @@ def _create_connection_from_config(config: dict) -> BaseBackend: parquet_tables = config.pop("tables", None) - # Try xorq first (handles env var substitution automatically) - try: - kwargs_tuple = tuple(sorted((k, v) for k, v in config.items() if k != "type")) - xorq_profile = XorqProfile(con_name=conn_type, kwargs_tuple=kwargs_tuple) - connection = xorq_profile.get_con() - except AssertionError: - # Backend not supported by xorq (e.g. Databricks) — fall back to plain ibis - connect_fn = getattr(ibis, conn_type, None) - if connect_fn is None or not callable(getattr(connect_fn, "connect", None)): - raise ProfileError(f"Unknown backend type: '{conn_type}'") - connect_kwargs = {k: v for k, v in config.items() if k != "type"} - # Expand env vars in string values (xorq does this automatically) - connect_kwargs = { - k: os.path.expandvars(v) if isinstance(v, str) else v - for k, v in connect_kwargs.items() - } - connection = connect_fn.connect(**connect_kwargs) + if HAS_XORQ: + # Try xorq first (handles env var substitution automatically) + try: + kwargs_tuple = tuple(sorted((k, v) for k, v in config.items() if k != "type")) + xorq_profile = XorqProfile(con_name=conn_type, kwargs_tuple=kwargs_tuple) + connection = xorq_profile.get_con() + except AssertionError: + connection = _connect_plain_ibis(ibis, config, conn_type) + else: + connection = _connect_plain_ibis(ibis, config, conn_type) # Load parquet tables if specified if parquet_tables: diff --git a/src/boring_semantic_layer/query.py b/src/boring_semantic_layer/query.py index 37d1db5c..054325f1 100644 --- a/src/boring_semantic_layer/query.py +++ b/src/boring_semantic_layer/query.py @@ -206,18 +206,32 @@ def to_callable(self) -> Callable: if isinstance(self.filter, dict): pred = pred_mod.from_dict(self.filter) ibis_module = _get_ibis_api() - return lambda t: pred_mod.compile( - pred, - ibis_module._, - ibis_module=ibis_module, - ).resolve(_ensure_xorq_table(t)) + + def _dict_filter(t): + return pred_mod.compile( + pred, + ibis_module._, + ibis_module=ibis_module, + ).resolve(_ensure_xorq_table(t)) + + # Deferred resolution: columns can't be statically introspected + # (see ops._dimension_only_source_table). Marked so callers can opt + # out of static-column optimizations consistently regardless of + # whether xorq is installed. + _dict_filter.__bsl_deferred_resolution__ = True + return _dict_filter elif isinstance(self.filter, str): _ibis = _get_ibis_api() expr = safe_eval( self.filter, context={"_": _ibis._, "ibis": _ibis}, ).unwrap() - return lambda t: expr.resolve(_ensure_xorq_table(t)) + + def _str_filter(t): + return expr.resolve(_ensure_xorq_table(t)) + + _str_filter.__bsl_deferred_resolution__ = True + return _str_filter elif callable(self.filter): return self.filter raise ValueError("Filter must be a dict, string, or callable") diff --git a/src/boring_semantic_layer/serialization/__init__.py b/src/boring_semantic_layer/serialization/__init__.py index 8afec22e..b5f22f54 100644 --- a/src/boring_semantic_layer/serialization/__init__.py +++ b/src/boring_semantic_layer/serialization/__init__.py @@ -157,8 +157,19 @@ def from_tagged(tagged_expr, context: BSLSerializationContext | None = None): BSL expression reconstructed from metadata Raises: + ImportError: If xorq is not installed ValueError: If no BSL metadata found in expression """ + result = try_import_xorq() + if isinstance(result, Failure): + error = result.failure() + if isinstance(error, ImportError): + raise ImportError( + "Xorq conversion requires the 'xorq' optional dependency. " + "Install with: pip install 'boring-semantic-layer[xorq]'" + ) from error + raise error + if context is None: context = BSLSerializationContext() diff --git a/src/boring_semantic_layer/serialization/tag_handler.py b/src/boring_semantic_layer/serialization/tag_handler.py index e141b249..2b050336 100644 --- a/src/boring_semantic_layer/serialization/tag_handler.py +++ b/src/boring_semantic_layer/serialization/tag_handler.py @@ -16,7 +16,7 @@ from typing import Any -from .._xorq import TagHandler +from .._xorq import HAS_XORQ, TagHandler from . import ( BSLSerializationContext, @@ -117,15 +117,17 @@ def reemit(tag_node, rebuild_subexpr): return new_source.hashing_tag(tag=tag_name, **meta) -_handler_kwargs = dict( - tag_names=("bsl",), - extract_metadata=extract_metadata, - from_tag_node=from_tag_node, -) -if "reemit" in {a.name for a in TagHandler.__attrs_attrs__}: - _handler_kwargs["reemit"] = reemit - -bsl_tag_handler = TagHandler(**_handler_kwargs) +if HAS_XORQ: + _handler_kwargs = dict( + tag_names=("bsl",), + extract_metadata=extract_metadata, + from_tag_node=from_tag_node, + ) + if "reemit" in {a.name for a in TagHandler.__attrs_attrs__}: + _handler_kwargs["reemit"] = reemit + bsl_tag_handler = TagHandler(**_handler_kwargs) +else: + bsl_tag_handler = None __all__ = [ diff --git a/src/boring_semantic_layer/tests/integration/conftest.py b/src/boring_semantic_layer/tests/integration/conftest.py index 902508f6..e9c645c1 100644 --- a/src/boring_semantic_layer/tests/integration/conftest.py +++ b/src/boring_semantic_layer/tests/integration/conftest.py @@ -13,6 +13,15 @@ import pytest from toolz import curry +# The Malloy comparison fixtures load BSL query modules (``malloy_data/*.py``) +# that import ``xorq.api`` directly, so the whole directory requires xorq. Skip +# collecting it when xorq is absent (the no-xorq CI leg) rather than relying on a +# ``--ignore`` CLI flag, so the gate lives with the tests that need it. +try: + import xorq # noqa: F401 +except ImportError: + collect_ignore_glob = ["*"] + TEST_CASES: tuple[tuple[str, str, tuple[str, ...]], ...] = ( ("comparing_timeframe", "query_1", ()), ("comparing_timeframe", "query_2", ()), diff --git a/src/boring_semantic_layer/tests/test_config_projection_pushdown.py b/src/boring_semantic_layer/tests/test_config_projection_pushdown.py index d3942bfa..bca9e95d 100644 --- a/src/boring_semantic_layer/tests/test_config_projection_pushdown.py +++ b/src/boring_semantic_layer/tests/test_config_projection_pushdown.py @@ -15,9 +15,12 @@ from boring_semantic_layer import to_semantic_table, to_untagged -# Projection pushdown disabled for xorq compatibility +# Projection pushdown is disabled in BSL (for xorq-vendored ibis compatibility), +# so these tests xfail regardless of whether xorq is installed. A few incidentally +# xpass under plain ibis where the emitted SQL happens to match; that is not the +# feature working, so the marker is intentionally unconditional and non-strict. pytestmark = pytest.mark.xfail( - reason="Projection pushdown disabled for xorq vendored ibis compatibility" + reason="Projection pushdown disabled for xorq-vendored ibis compatibility" ) diff --git a/src/boring_semantic_layer/tests/test_date_filter_fix.py b/src/boring_semantic_layer/tests/test_date_filter_fix.py index 314f92de..9213440a 100644 --- a/src/boring_semantic_layer/tests/test_date_filter_fix.py +++ b/src/boring_semantic_layer/tests/test_date_filter_fix.py @@ -9,6 +9,10 @@ import ibis import pytest +# BSL builds filter expressions with xorq's vendored ibis when xorq is +# installed; use the same flavor (plain ibis otherwise) so to_sql can compile +# the expression that Filter.to_callable produced. +from boring_semantic_layer._xorq import ibis as xibis from boring_semantic_layer.api import to_semantic_table from boring_semantic_layer.query import Filter, query @@ -220,10 +224,20 @@ def test_numeric_values_unchanged(self, orders_table): class TestSQLGeneration: """Test SQL generation for different backends.""" - def test_trino_sql_generation(self): - """Test that Trino/Athena SQL uses proper date functions.""" - from xorq.vendor import ibis as xibis - + @pytest.mark.parametrize( + "dialect, expected_fns", + [ + ("trino", ("FROM_ISO8601_TIMESTAMP",)), + ("duckdb", ("MAKE_TIMESTAMP", "MAKE_DATE")), + ], + ) + def test_sql_generation_uses_typed_date_functions(self, dialect, expected_fns): + """Generated SQL uses proper typed date functions per dialect. + + Compiles the filter expression to a dialect string via ibis/xorq's + compiler (no live backend required) and asserts the emitted SQL uses a + typed date constructor rather than a raw string literal. + """ con = ibis.duckdb.connect(":memory:") t = con.create_table( "test", @@ -234,49 +248,20 @@ def test_trino_sql_generation(self): filter_obj = Filter(filter={"field": "date_col", "operator": ">=", "value": "2024-01-01"}) filtered = filter_obj.to_callable()(t) - sql = xibis.to_sql(filtered, dialect="trino") + sql = xibis.to_sql(filtered, dialect=dialect) - # Should contain Trino date function - assert "FROM_ISO8601_TIMESTAMP" in sql - - def test_duckdb_sql_generation(self): - """Test that DuckDB SQL uses proper date functions.""" - from xorq.vendor import ibis as xibis - - con = ibis.duckdb.connect(":memory:") - t = con.create_table( - "test", - {"date_col": ["2024-01-01", "2024-06-15"]}, - schema={"date_col": "date"}, - ) - - filter_obj = Filter(filter={"field": "date_col", "operator": ">=", "value": "2024-01-01"}) - filtered = filter_obj.to_callable()(t) - - sql = xibis.to_sql(filtered, dialect="duckdb") - - # Should contain DuckDB date function - assert "MAKE_TIMESTAMP" in sql or "MAKE_DATE" in sql + assert any(fn in sql for fn in expected_fns) class TestFilterValueConversion: """Test the _convert_filter_value method directly.""" - def test_convert_date_string(self): - """Test conversion of date string.""" - f = Filter(filter={"field": "x", "operator": "=", "value": "2024-01-01"}) - result = f._convert_filter_value("2024-01-01") - from xorq.vendor.ibis.expr.types.temporal import TimestampScalar as XTimestampScalar - - assert isinstance(result, (ibis.expr.types.temporal.TimestampScalar, XTimestampScalar)) - - def test_convert_timestamp_string(self): - """Test conversion of timestamp string.""" - from xorq.vendor.ibis.expr.types.temporal import TimestampScalar as XTimestampScalar - - f = Filter(filter={"field": "x", "operator": "=", "value": "2024-01-01"}) - result = f._convert_filter_value("2024-01-01T12:00:00") - assert isinstance(result, (ibis.expr.types.temporal.TimestampScalar, XTimestampScalar)) + @pytest.mark.parametrize("value", ["2024-01-01", "2024-01-01T12:00:00"]) + def test_convert_date_string_to_timestamp_literal(self, value): + """Date/timestamp strings convert to typed timestamp literals.""" + f = Filter(filter={"field": "x", "operator": "=", "value": value}) + result = f._convert_filter_value(value) + assert result.type().is_timestamp() def test_non_date_string_passthrough(self): """Test that non-date strings pass through unchanged.""" diff --git a/src/boring_semantic_layer/tests/test_dependency_groups.py b/src/boring_semantic_layer/tests/test_dependency_groups.py index bdfd2c0f..71e55604 100644 --- a/src/boring_semantic_layer/tests/test_dependency_groups.py +++ b/src/boring_semantic_layer/tests/test_dependency_groups.py @@ -5,13 +5,14 @@ they receive clear error messages indicating which dependency group to install. Dependency groups in pyproject.toml: +- xorq: For tagged-expression serialization (to_tagged/from_tagged), caching - mcp: For MCP semantic model functionality (MCPSemanticModel) - agent: For LangChain-based query agents - viz-altair: For Altair visualization (chart with backend="altair") - viz-plotly: For Plotly visualization (chart with backend="plotly") - examples: For running examples (includes xorq and duckdb) -Note: xorq is a core dependency (not optional) for window compatibility. +Note: xorq is an optional dependency; core BSL works without it. """ import sys @@ -46,6 +47,7 @@ def test_pyproject_has_all_optional_dependencies(self): optional_deps = pyproject["project"]["optional-dependencies"] # Verify all expected groups exist + assert "xorq" in optional_deps, "xorq dependency group missing" assert "mcp" in optional_deps, "mcp dependency group missing" assert "agent" in optional_deps, "agent dependency group missing" assert "viz-altair" in optional_deps, "viz-altair dependency group missing" @@ -53,6 +55,7 @@ def test_pyproject_has_all_optional_dependencies(self): assert "examples" in optional_deps, "examples dependency group missing" # Verify key dependencies in each group + assert any("xorq" in dep for dep in optional_deps["xorq"]) assert any("fastmcp" in dep for dep in optional_deps["mcp"]) assert any("langchain" in dep for dep in optional_deps["agent"]) assert any("altair" in dep for dep in optional_deps["viz-altair"]) @@ -60,7 +63,7 @@ def test_pyproject_has_all_optional_dependencies(self): assert any("xorq" in dep for dep in optional_deps["examples"]) def test_all_dependency_groups_in_dev(self): - """Verify dev dependency group includes all optional dependencies.""" + """Verify dev dependency group contains developer tooling (not a self-referential bundle).""" # Use tomllib (Python 3.11+) or tomli (Python 3.10) if sys.version_info >= (3, 11): import tomllib @@ -78,22 +81,21 @@ def test_all_dependency_groups_in_dev(self): pyproject = tomllib.load(f) dev_deps = pyproject["project"]["optional-dependencies"]["dev"] + dev_deps_str = str(dev_deps) - # Dev should include all optional dependency groups - # Check for the pattern boring-semantic-layer[...] in dev deps - dev_with_extras = [dep for dep in dev_deps if "boring-semantic-layer[" in dep] + # Dev should NOT use a self-referential boring-semantic-layer[...] bundle; + # optional extras (xorq, mcp, etc.) are installed separately via --all-extras. + dev_with_self_ref = [dep for dep in dev_deps if "boring-semantic-layer[" in dep] + assert len(dev_with_self_ref) == 0, ( + "Dev should not use a self-referential bundle; install extras via --all-extras" + ) - assert len(dev_with_extras) > 0, "Dev should include boring-semantic-layer with extras" - - # The first dev dependency should include all the optional groups (not xorq, it's required) - if dev_with_extras: - first_dep = dev_with_extras[0] - assert "mcp" in first_dep - assert "examples" in first_dep - assert "agent" in first_dep - assert "viz-altair" in first_dep - assert "viz-plotly" in first_dep - assert "viz-plotext" in first_dep + # Dev should contain developer tooling + assert any("ruff" in dep for dep in dev_deps), "Dev should include ruff" + assert any("pre-commit" in dep for dep in dev_deps), "Dev should include pre-commit" + assert any("langchain-anthropic" in dep or "langchain-openai" in dep for dep in dev_deps), ( + "Dev should include LLM provider clients for testing" + ) class TestXorqErrorMessages: @@ -210,7 +212,7 @@ def test_all_features_with_optional_deps_documented(self): assert "viz-plotly" in test_file_content or "plotly" in test_file_content def test_pyproject_dev_group_is_comprehensive(self): - """Verify dev group in pyproject.toml includes all optional dependencies.""" + """Verify all optional dependency groups are declared in pyproject.toml.""" # Use tomllib (Python 3.11+) or tomli (Python 3.10) if sys.version_info >= (3, 11): import tomllib @@ -226,18 +228,14 @@ def test_pyproject_dev_group_is_comprehensive(self): with open(pyproject_path, "rb") as f: pyproject = tomllib.load(f) - # Get all optional dependency group names (excluding dev itself and examples) optional_deps = pyproject["project"]["optional-dependencies"] - optional_groups = [group for group in optional_deps if group not in ["dev", "examples"]] - - # Dev dependencies string - dev_deps_str = str(pyproject["project"]["optional-dependencies"]["dev"]) - # Check that dev group references all other optional groups - # It should have boring-semantic-layer[group1,group2,...] - for group in optional_groups: - assert group in dev_deps_str, ( - f"Dev group should include optional dependency group: {group}" + # All user-facing extras must exist as top-level optional-dependencies keys. + # (Extras are no longer bundled into dev; they are installed via --all-extras in CI.) + expected_extras = {"xorq", "mcp", "agent", "viz-altair", "viz-plotly", "viz-plotext"} + for group in expected_extras: + assert group in optional_deps, ( + f"Optional dependency group '{group}' missing from pyproject.toml" ) @@ -261,19 +259,19 @@ def test_xorq_available_if_installed(self): assert callable(to_tagged) assert callable(from_tagged) else: - # xorq not installed, verify we get helpful error - with pytest.raises((ImportError, AttributeError)) as exc_info: + # xorq not installed, verify we get helpful error with a + # schema-only table (avoids pandas/pyarrow dependency) + with pytest.raises(ImportError) as exc_info: import ibis from boring_semantic_layer import SemanticModel from boring_semantic_layer.serialization import to_tagged - table = ibis.memtable({"a": [1]}) + table = ibis.table({"a": "int64"}, "test") model = SemanticModel(table=table, dimensions={}, measures={}) to_tagged(model) - # Should mention xorq in the error - assert "xorq" in str(exc_info.value).lower() or "xorq" in str(exc_info.typename).lower() + assert "xorq" in str(exc_info.value).lower() def test_mcp_available_if_installed(self): """Verify MCPSemanticModel works when fastmcp is installed.""" diff --git a/src/boring_semantic_layer/tests/test_index.py b/src/boring_semantic_layer/tests/test_index.py index 27af3d4a..96480139 100644 --- a/src/boring_semantic_layer/tests/test_index.py +++ b/src/boring_semantic_layer/tests/test_index.py @@ -14,7 +14,9 @@ import ibis import pandas as pd import pytest -import xorq.api as xo + +xorq = pytest.importorskip("xorq", reason="xorq not installed") +import xorq.api as xo # noqa: E402 from boring_semantic_layer import to_semantic_table diff --git a/src/boring_semantic_layer/tests/test_malloy_inspired.py b/src/boring_semantic_layer/tests/test_malloy_inspired.py index 52ed0a6e..94e74949 100644 --- a/src/boring_semantic_layer/tests/test_malloy_inspired.py +++ b/src/boring_semantic_layer/tests/test_malloy_inspired.py @@ -20,7 +20,9 @@ import pandas as pd import pytest from ibis import _ -import xorq.api as xo + +xorq = pytest.importorskip("xorq", reason="xorq not installed") +import xorq.api as xo # noqa: E402 from boring_semantic_layer import to_semantic_table diff --git a/src/boring_semantic_layer/tests/test_malloy_xorq_roundtrip.py b/src/boring_semantic_layer/tests/test_malloy_xorq_roundtrip.py index 2f9366f4..793b48a1 100644 --- a/src/boring_semantic_layer/tests/test_malloy_xorq_roundtrip.py +++ b/src/boring_semantic_layer/tests/test_malloy_xorq_roundtrip.py @@ -10,19 +10,11 @@ import pytest -from boring_semantic_layer.serialization import from_tagged, to_tagged, try_import_xorq +from boring_semantic_layer.serialization import from_tagged, to_tagged -# Check if xorq is available -try: - try_import_xorq() - xorq_available = True - xorq_skip_reason = "" -except ImportError: - xorq_available = False - xorq_skip_reason = "xorq not installed" +pytest.importorskip("xorq", reason="xorq not installed") -@pytest.mark.skipif(not xorq_available, reason=xorq_skip_reason) class TestMalloyModelsRoundTrip: """Test round-trip conversion for all Malloy-inspired BSL models.""" @@ -385,7 +377,6 @@ def test_multiple_measures_roundtrip(self): assert len(reconstructed_data) == 3 -@pytest.mark.skipif(not xorq_available, reason=xorq_skip_reason) class TestMalloyXorqFeatures: """Test that xorq-specific features work with Malloy-style BSL models.""" diff --git a/src/boring_semantic_layer/tests/test_measure_reference_styles.py b/src/boring_semantic_layer/tests/test_measure_reference_styles.py index fd802073..9a37329c 100644 --- a/src/boring_semantic_layer/tests/test_measure_reference_styles.py +++ b/src/boring_semantic_layer/tests/test_measure_reference_styles.py @@ -533,6 +533,7 @@ def test_method_call_serialization_roundtrip(): Replaces the legacy curated-AST direct construction with the behavioral round-trip through ``to_tagged`` / ``from_tagged``. """ + pytest.importorskip("xorq") from boring_semantic_layer import to_semantic_table from boring_semantic_layer.serialization import from_tagged, to_tagged diff --git a/src/boring_semantic_layer/tests/test_real_world_scenarios.py b/src/boring_semantic_layer/tests/test_real_world_scenarios.py index 3d86407e..0403ed14 100644 --- a/src/boring_semantic_layer/tests/test_real_world_scenarios.py +++ b/src/boring_semantic_layer/tests/test_real_world_scenarios.py @@ -11,7 +11,9 @@ import ibis import pandas as pd import pytest -import xorq.api as xo + +xorq = pytest.importorskip("xorq", reason="xorq not installed") +import xorq.api as xo # noqa: E402 from boring_semantic_layer import to_semantic_table diff --git a/src/boring_semantic_layer/tests/test_rewrites_projection_pushdown.py b/src/boring_semantic_layer/tests/test_rewrites_projection_pushdown.py index 7e45b641..6fda2ac0 100644 --- a/src/boring_semantic_layer/tests/test_rewrites_projection_pushdown.py +++ b/src/boring_semantic_layer/tests/test_rewrites_projection_pushdown.py @@ -14,9 +14,12 @@ from boring_semantic_layer import to_semantic_table, to_untagged -# Projection pushdown disabled for xorq compatibility +# Projection pushdown is disabled in BSL (for xorq-vendored ibis compatibility), +# so these tests xfail regardless of whether xorq is installed. A few incidentally +# xpass under plain ibis where the emitted SQL happens to match; that is not the +# feature working, so the marker is intentionally unconditional and non-strict. pytestmark = pytest.mark.xfail( - reason="Projection pushdown disabled for xorq vendored ibis compatibility" + reason="Projection pushdown disabled for xorq-vendored ibis compatibility" ) diff --git a/src/boring_semantic_layer/tests/test_rewrites_projection_pushdown_examples.py b/src/boring_semantic_layer/tests/test_rewrites_projection_pushdown_examples.py index bc87e881..10cb23a8 100644 --- a/src/boring_semantic_layer/tests/test_rewrites_projection_pushdown_examples.py +++ b/src/boring_semantic_layer/tests/test_rewrites_projection_pushdown_examples.py @@ -20,9 +20,12 @@ from boring_semantic_layer.api import to_semantic_table from boring_semantic_layer.expr import to_untagged -# Projection pushdown disabled for xorq compatibility +# Projection pushdown is disabled in BSL (for xorq-vendored ibis compatibility), +# so these tests xfail regardless of whether xorq is installed. A few incidentally +# xpass under plain ibis where the emitted SQL happens to match; that is not the +# feature working, so the marker is intentionally unconditional and non-strict. pytestmark = pytest.mark.xfail( - reason="Projection pushdown disabled for xorq vendored ibis compatibility" + reason="Projection pushdown disabled for xorq-vendored ibis compatibility" ) diff --git a/src/boring_semantic_layer/tests/test_upstream_ibis_pins.py b/src/boring_semantic_layer/tests/test_upstream_ibis_pins.py index 9ef2321a..7ce92982 100644 --- a/src/boring_semantic_layer/tests/test_upstream_ibis_pins.py +++ b/src/boring_semantic_layer/tests/test_upstream_ibis_pins.py @@ -22,7 +22,9 @@ import pytest import ibis as plain_ibis -from xorq.common.utils.ibis_utils import from_ibis + +pytest.importorskip("xorq", reason="xorq not installed") +from xorq.common.utils.ibis_utils import from_ibis # noqa: E402 def _make_table(flavor: str, name: str, df: pd.DataFrame): diff --git a/src/boring_semantic_layer/tests/test_xorq_backends.py b/src/boring_semantic_layer/tests/test_xorq_backends.py index 642b7788..93c09dc3 100644 --- a/src/boring_semantic_layer/tests/test_xorq_backends.py +++ b/src/boring_semantic_layer/tests/test_xorq_backends.py @@ -14,21 +14,11 @@ import pytest from boring_semantic_layer import SemanticModel -from boring_semantic_layer.serialization import from_tagged, to_tagged, try_import_xorq +from boring_semantic_layer.serialization import from_tagged, to_tagged -# Check if xorq is available -try: - try_import_xorq() - import xorq.api as xo +xo = pytest.importorskip("xorq.api", reason="xorq not installed") - xorq_available = True - xorq_skip_reason = "" -except ImportError: - xorq_available = False - xorq_skip_reason = "xorq not installed" - -@pytest.mark.skipif(not xorq_available, reason=xorq_skip_reason) class TestXorqDuckDBBackend: """Test BSL with xorq's DuckDB backend.""" @@ -123,7 +113,6 @@ def test_duckdb_with_pyarrow_batches(self): assert all_data == list(range(100)) -@pytest.mark.skipif(not xorq_available, reason=xorq_skip_reason) class TestXorqDataFusionBackend: """Test BSL with xorq's DataFusion backend.""" @@ -162,7 +151,6 @@ def test_datafusion_aggregation(self): assert set(df["grp"]) == {"A", "B"} -@pytest.mark.skipif(not xorq_available, reason=xorq_skip_reason) class TestXorqPandasBackend: """Test BSL with xorq's Pandas backend.""" @@ -192,7 +180,6 @@ def test_pandas_backend_execution(self): assert list(df["y"]) == [4, 5, 6] -@pytest.mark.skipif(not xorq_available, reason=xorq_skip_reason) class TestXorqBackendSwitching: """Test switching between different xorq backends.""" @@ -223,7 +210,6 @@ def test_multiple_backends_isolation(self): assert backend2 is not None -@pytest.mark.skipif(not xorq_available, reason=xorq_skip_reason) class TestXorqBackendFeatures: """Test xorq-specific backend features with BSL.""" diff --git a/src/boring_semantic_layer/tests/test_xorq_integration.py b/src/boring_semantic_layer/tests/test_xorq_integration.py index 952fa126..28c02c16 100644 --- a/src/boring_semantic_layer/tests/test_xorq_integration.py +++ b/src/boring_semantic_layer/tests/test_xorq_integration.py @@ -8,19 +8,11 @@ import pytest from boring_semantic_layer import SemanticModel -from boring_semantic_layer.serialization import from_tagged, to_tagged, try_import_xorq +from boring_semantic_layer.serialization import from_tagged, to_tagged -# Check if xorq is available -try: - try_import_xorq() - xorq_available = True - xorq_skip_reason = "" -except ImportError: - xorq_available = False - xorq_skip_reason = "xorq not installed" +pytest.importorskip("xorq", reason="xorq not installed") -@pytest.mark.skipif(not xorq_available, reason=xorq_skip_reason) class TestXorqIntegration: """Integration tests for xorq conversion.""" @@ -204,7 +196,6 @@ def test_filtered_expression_to_xorq(self): assert min(df["a"]) > 2 -@pytest.mark.skipif(not xorq_available, reason=xorq_skip_reason) class TestXorqFeatures: """Test xorq-specific features that aren't available in regular ibis.""" diff --git a/src/boring_semantic_layer/tests/test_xorq_rebuild.py b/src/boring_semantic_layer/tests/test_xorq_rebuild.py index a32431d7..443a21e1 100644 --- a/src/boring_semantic_layer/tests/test_xorq_rebuild.py +++ b/src/boring_semantic_layer/tests/test_xorq_rebuild.py @@ -16,15 +16,15 @@ import ibis import pytest -from boring_semantic_layer import SemanticModel -from boring_semantic_layer.serialization import to_tagged -from boring_semantic_layer.serialization.tag_handler import ( +xorq = pytest.importorskip("xorq", reason="xorq not installed") + +from boring_semantic_layer import SemanticModel # noqa: E402 +from boring_semantic_layer.serialization import to_tagged # noqa: E402 +from boring_semantic_layer.serialization.tag_handler import ( # noqa: E402 bsl_tag_handler, reemit, ) -xorq = pytest.importorskip("xorq", reason="xorq not installed") - from xorq.expr.builders import TagHandler as _TagHandler _has_reemit = "reemit" in {a.name for a in _TagHandler.__attrs_attrs__} diff --git a/src/boring_semantic_layer/tests/test_xorq_string_serialization.py b/src/boring_semantic_layer/tests/test_xorq_string_serialization.py index 337a1219..efe9f40f 100644 --- a/src/boring_semantic_layer/tests/test_xorq_string_serialization.py +++ b/src/boring_semantic_layer/tests/test_xorq_string_serialization.py @@ -3,6 +3,8 @@ import ibis import pytest +pytest.importorskip("xorq", reason="xorq not installed") + from boring_semantic_layer import to_semantic_table diff --git a/src/boring_semantic_layer/tests/test_xorq_tag_handler.py b/src/boring_semantic_layer/tests/test_xorq_tag_handler.py index a99ba611..34d56f41 100644 --- a/src/boring_semantic_layer/tests/test_xorq_tag_handler.py +++ b/src/boring_semantic_layer/tests/test_xorq_tag_handler.py @@ -17,6 +17,8 @@ import ibis import pytest +pytest.importorskip("xorq", reason="xorq not installed") + from boring_semantic_layer import SemanticModel, to_semantic_table from boring_semantic_layer.serialization import to_tagged from boring_semantic_layer.serialization.tag_handler import ( diff --git a/uv.lock b/uv.lock index ce21a603..02cf7946 100644 --- a/uv.lock +++ b/uv.lock @@ -184,7 +184,6 @@ dependencies = [ { name = "pyyaml" }, { name = "returns" }, { name = "toolz" }, - { name = "xorq" }, ] [package.optional-dependencies] @@ -195,30 +194,16 @@ agent = [ { name = "rich" }, ] dev = [ - { name = "altair" }, - { name = "duckdb" }, - { name = "fastapi" }, - { name = "fastmcp" }, - { name = "kaleido" }, - { name = "langchain" }, { name = "langchain-anthropic" }, { name = "langchain-openai" }, { name = "malloy" }, - { name = "nbformat" }, { name = "openai" }, { name = "pandas" }, - { name = "plotext" }, - { name = "plotly" }, { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-asyncio" }, - { name = "python-dotenv" }, - { name = "rich" }, { name = "ruff" }, { name = "urllib3" }, - { name = "uvicorn", extra = ["standard"] }, - { name = "vl-convert-python" }, - { name = "xorq", extra = ["duckdb"] }, ] examples = [ { name = "duckdb" }, @@ -231,6 +216,12 @@ server = [ { name = "fastapi" }, { name = "uvicorn", extra = ["standard"] }, ] +test-core = [ + { name = "ibis-framework", extra = ["duckdb"] }, + { name = "pandas" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, +] viz-altair = [ { name = "altair" }, { name = "vl-convert-python" }, @@ -243,6 +234,9 @@ viz-plotly = [ { name = "nbformat" }, { name = "plotly" }, ] +xorq = [ + { name = "xorq" }, +] [package.dev-dependencies] dev = [ @@ -254,11 +248,11 @@ dev = [ requires-dist = [ { name = "altair", marker = "extra == 'viz-altair'", specifier = ">=5.0.0" }, { name = "attrs", specifier = ">=25.3.0" }, - { name = "boring-semantic-layer", extras = ["agent", "mcp", "server", "examples", "viz-altair", "viz-plotly", "viz-plotext"], marker = "extra == 'dev'" }, { name = "duckdb", marker = "extra == 'examples'", specifier = "<1.4" }, { name = "fastapi", marker = "extra == 'server'", specifier = ">=0.115.0" }, { name = "fastmcp", marker = "extra == 'mcp'", specifier = ">=2.12.4" }, { name = "ibis-framework", specifier = ">=11.0.0" }, + { name = "ibis-framework", extras = ["duckdb"], marker = "extra == 'test-core'", specifier = ">=11.0.0" }, { name = "kaleido", marker = "extra == 'viz-plotly'" }, { name = "langchain", marker = "extra == 'agent'", specifier = ">=0.3.0" }, { name = "langchain-anthropic", marker = "extra == 'dev'", specifier = ">=0.3.0" }, @@ -268,12 +262,15 @@ requires-dist = [ { name = "openai", marker = "extra == 'dev'", specifier = ">=1.0.0" }, { name = "packaging" }, { name = "pandas", marker = "extra == 'dev'", specifier = ">=2.3.0" }, + { name = "pandas", marker = "extra == 'test-core'", specifier = ">=2.3.0" }, { name = "plotext", marker = "extra == 'agent'", specifier = ">=5.0.0" }, { name = "plotext", marker = "extra == 'viz-plotext'", specifier = ">=5.0.0" }, { name = "plotly", marker = "extra == 'viz-plotly'", specifier = ">=6.3.0" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.2.0" }, { name = "pytest", marker = "extra == 'dev'" }, + { name = "pytest", marker = "extra == 'test-core'" }, { name = "pytest-asyncio", marker = "extra == 'dev'" }, + { name = "pytest-asyncio", marker = "extra == 'test-core'" }, { name = "python-dotenv", marker = "extra == 'agent'", specifier = ">=1.0.0" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "returns", specifier = ">=0.26.0" }, @@ -283,11 +280,10 @@ requires-dist = [ { name = "urllib3", marker = "extra == 'dev'", specifier = ">=2.2.3" }, { name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = ">=0.30.0" }, { name = "vl-convert-python", marker = "extra == 'viz-altair'", specifier = ">=1.0.0" }, - { name = "xorq", specifier = ">=0.3.25" }, - { name = "xorq", marker = "extra == 'examples'" }, - { name = "xorq", extras = ["duckdb"], marker = "extra == 'examples'", specifier = ">=0.3.4" }, + { name = "xorq", marker = "extra == 'xorq'", specifier = ">=0.3.25" }, + { name = "xorq", extras = ["duckdb"], marker = "extra == 'examples'", specifier = ">=0.3.25" }, ] -provides-extras = ["examples", "viz-altair", "viz-plotly", "viz-plotext", "agent", "mcp", "server", "dev"] +provides-extras = ["xorq", "examples", "viz-altair", "viz-plotly", "viz-plotext", "agent", "mcp", "server", "test-core", "dev"] [package.metadata.requires-dev] dev = [{ name = "ipython", specifier = ">=8.38.0" }] @@ -1269,6 +1265,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/86/c0/2851a8a55d0fea03b80fd45815069b686e032938fc68fa9d91ac776c148c/ibis_framework-11.0.0-py3-none-any.whl", hash = "sha256:92ff82a96f4eac7f86fa9b6a315e04b5a8f9ed3d186539d88f48e628363f2e72", size = 1935652, upload-time = "2025-10-15T13:12:07.954Z" }, ] +[package.optional-dependencies] +duckdb = [ + { name = "duckdb" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.3.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "pyarrow" }, + { name = "pyarrow-hotfix" }, + { name = "rich" }, +] + [[package]] name = "identify" version = "2.6.15"