refactor: make xorq an optional (extra) dependency#271
Conversation
xorq is removed from core dependencies and moved to an optional
`[xorq]` extra. `pip install boring-semantic-layer` now installs and
works without xorq; core querying, YAML round-trips, and named
profiles all function against plain ibis backends.
Key changes:
- `_xorq.py`: dual-source shim — tries xorq, falls back to plain
ibis equivalents; exposes `HAS_XORQ` flag; provides pure-Python
fallbacks for `to_node`, `walk_nodes`, `replace_nodes`, `from_ibis`
(identity), and a `map_ibis` stub
- `profile.py`: gates XorqProfile path on `HAS_XORQ`; skips xorq_dir
discovery when xorq absent
- `serialization/__init__.py`: guards `from_tagged` with the same
`try_import_xorq()` + ImportError pattern already used in `to_tagged`
- `serialization/tag_handler.py`: guards module-level TagHandler usage
with `HAS_XORQ`; `bsl_tag_handler = None` when xorq absent
- `pyproject.toml`: xorq moved to `[xorq]` optional extra; `dev` gains
`xorq`; new `test-core` extra for xorq-free CI
- CI: adds `test-no-xorq` job that installs without xorq and asserts
the suite passes (xorq tests skip, not error)
- Tests: `importorskip("xorq")` added to 5 files with top-level xorq
imports; fixed xorq-availability detection in two roundtrip test
files; `test_xorq_rebuild.py` importorskip moved before tag_handler
import; `test_dependency_groups.py` updated for new extra structure
Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>
- Gate deploy-docs and release-pypi on test-no-xorq so a xorq-free regression cannot slip through to a PyPI release - Align examples extra floor with the new xorq extra (>=0.3.25) and drop the redundant bare "xorq" pin - Remove explicit pyarrow from test-core; it arrives transitively via ibis-framework[duckdb] and BSL has no direct pyarrow import Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>
- Switch from `uv pip install --system ".[test-core,mcp]"` to `uv sync --all-extras --no-extra xorq --no-extra examples --no-extra dev` so the no-xorq environment mirrors the full dev install, minus the three extras that directly depend on xorq as a package. - Use `uv run python` / `uv run pytest` consistently (no bare executables). - Drop the hardcoded test path and `-x -q` flags; let pytest discover naturally (same as `make test`) and just ignore the integration suite which requires external services not set up in this job. Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>
4a76265 to
7e88f23
Compare
The rolling-window regression test used xorq.api.window, causing a module-level import of xorq and a collection error in the test-no-xorq CI job. ibis.window accepts the same rows=/order_by= arguments, so there is no reason to depend on xorq here. Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Optional extras (xorq, mcp, agent, viz-*) are now installed via --all-extras in CI rather than a boring-semantic-layer[...] bundle in the dev extra, keeping dev as pure developer tooling. Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Use --no-extra xorq --no-extra examples so the test-no-xorq job validates core BSL without those optional dependencies. Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Add pytest.importorskip("xorq") at module level so these test files
are skipped cleanly in environments without the xorq extra.
Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>
xorq.vendor.ibis is no longer available; use the standard ibis import directly in test_date_filter_fix and demo_bsl_v2. Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>
- test_dependency_groups: assert dev has tooling (not a self-referential bundle) and that all optional extras exist as top-level keys - test_measure_reference_styles: skip serialization roundtrip test when xorq is not installed Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>
8e24508 to
e12ae33
Compare
deepyaman
left a comment
There was a problem hiding this comment.
Another round of feedback.
| ) | ||
|
|
||
| rolling_window = xo.window(order_by="date", preceding=1, following=1) | ||
| rolling_window = get_ibis().window(order_by="date", preceding=1, following=1) |
There was a problem hiding this comment.
I know I suggested this get_ibis(), but what's the point of Xorq here? The demo is otherwise entirely Ibis-based; why would we introduce a Xorq import just for window?
There was a problem hiding this comment.
BSL's to_untagged() returns xorq-vendored ibis when xorq is installed, and those expressions reject a plain ibis.window (LegacyWindowBuilder). The demo runs under the [xorq] extra in CI, so plain ibis.window would break there. Switched to the established shim idiom from boring_semantic_layer._xorq import ibis as xibis (as used in expr.py) — plain ibis without xorq, vendored only when present — with an explanatory comment. Also dropped the get_ibis() helper I'd added, since it diverged from that existing idiom. (5d97122)
| path: ./docs/web/dist | ||
|
|
||
| # Verify xorq is truly optional: install without it and run the core suite | ||
| test-no-xorq: |
There was a problem hiding this comment.
Feel like we should parametrize this workflow, and move the docs stuff into a separate step. Seems cleaner.
There was a problem hiding this comment.
Done in dd2eb03 — collapsed the two near-duplicate test jobs into a matrix over the xorq extras (full / no-xorq), and split examples + docs-build + skills-check into a dedicated build job. Coverage is preserved; release/deploy gates updated to needs: [test, build].
| .mutate( | ||
| rolling_avg=lambda t: t.flight_count.mean().over( | ||
| xo.window(rows=(-2, 0), order_by="flight_week") | ||
| get_ibis().window(rows=(-2, 0), order_by="flight_week") |
There was a problem hiding this comment.
Again, is Xorq necessary, or this could be pure Ibis? Does Xorq.window provide anything? Was there any justification for this in the blame; e.g. if Ibis window was missing some functionality that seems to be fixed now?
There was a problem hiding this comment.
Checked the blame: the xorq-vendored window came in via 6296e56 / 7328d40 because to_untagged() returns xorq-vendored ibis. It's still required today — this PR's own CI proved it: commit 9a61507 (which used ibis.window) failed the with-xorq job with XorqTypeError: Unexpected window type: LegacyWindowBuilder. So ibis.window is not "fixed" for this case. xorq.window isn't functionally different (same ibis code under xorq's vendored namespace); it's needed only for type identity. Now built via the xibis shim so it's plain ibis without xorq. (5d97122)
| @@ -222,8 +223,6 @@ class TestSQLGeneration: | |||
|
|
|||
| def test_trino_sql_generation(self): | |||
There was a problem hiding this comment.
Should the two SQL generation tests be parametrized?
Also, does doing to_sql specifying dialect actually test what we want? I would expect you need a Trino backend to see the true behavior; not sure if Xorq works differently from Ibis in this regard.
There was a problem hiding this comment.
Parametrized (5d97122). On the dialect question: ibis.to_sql(expr, dialect="trino") compiles through the dialect's compiler with no live backend — that's the standard, intended way to test dialect SQL generation. The assertion is on the emitted SQL string (a compilation concern), not execution, so a Trino backend isn't needed; xorq's vendored to_sql uses the same compiler. (This test predates the PR; we only swapped the ibis flavor.)
| from xorq.vendor.ibis.expr.types.temporal import TimestampScalar as XTimestampScalar | ||
|
|
||
| assert isinstance(result, (ibis.expr.types.temporal.TimestampScalar, XTimestampScalar)) | ||
| # Flavor-agnostic: a typed timestamp literal regardless of ibis flavor. |
There was a problem hiding this comment.
Comment necessary?
If it is, I'd expect it for the below test, too. The need for commenting twice is also why I suggested parametrizing the test above.
There was a problem hiding this comment.
Parametrized the two conversion tests into one (5d97122); the single result.type().is_timestamp() assertion is self-explanatory, so the comment is gone.
| - 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. |
There was a problem hiding this comment.
Is this true? Maybe should investigate/be very sure of this.
If this was the reason for making Xorq a core dependency, then we have a clear justification for making it optional again, if we can confidently prove whatever window issue is resolved. Please consider this carefully.
There was a problem hiding this comment.
Investigated carefully. The window-compat reason was real, but rooted in BSL vendoring (to_untagged() returns xorq-vendored ibis, 7328d40), not a missing ibis feature — and it is not "resolved": plain ibis.window still fails under xorq (verified by this PR's CI, commit 9a61507). It doesn't need to be resolved to make xorq optional, though — the incompatibility only exists when xorq is installed, and the shim selects the right flavor per environment. The dep-group note now describes the extra accurately (serialization/caching; optional).
| 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 |
There was a problem hiding this comment.
Is importorskip not more idiomatic? Is there a reason for doing it this way?
There was a problem hiding this comment.
Agreed — converted the three remaining files (test_malloy_xorq_roundtrip, test_xorq_integration, test_xorq_backends) to module-level pytest.importorskip("xorq"), matching the 11 files that already use it. Left test_dependency_groups.py as-is since its purpose is to test the dependency-group mechanism itself. (05e7b4a)
| return _create_connection_from_config(config) | ||
|
|
||
|
|
||
| _ENV_VAR_PATTERN = re.compile(r"\$\{(\w+)\}|\$(\w+)") |
There was a problem hiding this comment.
This seems like a separate fix that should, at the very least, be a separated commit. Probably, it could be its own PR, right?
There was a problem hiding this comment.
Nvm, maybe the connect plain Ibis and stuff indicate that this is coupled to this PR.
There was a problem hiding this comment.
Right — it's coupled: the bug only manifests on the new no-xorq _connect_plain_ibis() path (xorq's path handled env-var expansion). Kept in this PR, but as its own commit (1d64bdb).
os.path.expandvars silently leaves undefined ${VAR} references in place, so
without xorq the plain-ibis connection path turned `database: ${MISSING}` into
a DuckDB file literally named "${MISSING}" instead of failing. Add
_expand_env_vars(), which raises ProfileError on any undefined variable
(matching xorq's strict behavior). Coupled to this PR: the bug only manifests
on the new no-xorq _connect_plain_ibis() path.
Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>
Two code paths behaved differently when xorq was absent, because the shim's plain-ibis fallbacks don't replicate xorq's runtime semantics: - _rebind_join_backends / _rebind_to_canonical_backend: the try/except ImportError guard never fires now that the shim always provides walk_nodes/relations, so the plain-ibis path rebuilt an equal-but-distinct expression for no benefit. Without xorq there is only one backend, so return the inputs unchanged. - The dimension-only shortcut engaged or not depending on whether xorq was installed: with xorq, from_ibis(proxy) pollutes the tracking proxy so static column extraction fails (shortcut disabled); without xorq it succeeds (shortcut enabled), yielding a different result set for the same query. Dict/string filters resolve through the backend (deferred) and aren't statically introspectable, so they now explicitly disable the shortcut in both environments (tagged __bsl_deferred_resolution__ in query.py). Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>
BSL's to_untagged() returns xorq-vendored ibis when xorq is installed, and those expressions reject a plain ibis.window (LegacyWindowBuilder) and can't be compiled by plain ibis.to_sql. Build windows / call to_sql via the established shim idiom `from boring_semantic_layer._xorq import ibis as xibis` (as in expr.py), which is plain ibis without xorq and vendored ibis with it — so the no-xorq job exercises plain ibis and the full job exercises xorq. xorq.window is not functionally different; it's the same ibis code under xorq's namespace, needed only for type identity. Also parametrize the two SQL-generation tests and the two filter-value conversion tests, and assert flavor-agnostically via result.type().is_timestamp(). Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>
…fail
Convert the three remaining xorq-only test files from the manual
try/except + @skipif pattern to module-level pytest.importorskip("xorq"),
matching the eleven files that already use it (e.g. test_calc_analyzer.py).
test_dependency_groups.py is intentionally left as-is since it tests the
dependency-group mechanism itself.
Also clarify the projection-pushdown xfail comments: the marker is unconditional
(pushdown is disabled for all backends, not just xorq); the few xpasses under
plain ibis are incidental SQL matches, not the feature working.
Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>
This PR introduced a second, near-duplicate test job (test-no-xorq). Collapse both into one matrix over the install extras (full / no-xorq), each running the pytest suite (the no-xorq leg ignores integration, as before). Extract the xorq-only build steps (examples + docs-build + skills-check) into a dedicated build job, since the no-xorq leg can't run them. Update deploy-docs and release-pypi gates to needs: [test, build]. Coverage is preserved — the steps move, nothing is dropped. Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>
cd06970 to
dd2eb03
Compare
Relock from main's pins so the lockfile diff reflects only the extras restructure (no incidental version bumps), and resync requirements-dev.txt (which was already stale vs main's lock — xorq 0.3.5 listed, 0.3.25 locked). Add a comment on the test-core extra clarifying it is the no-xorq CI leg's baseline test environment, pulled in via 'uv sync --all-extras' to provide a duckdb backend (and pyarrow) without xorq or the examples extra. Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>
The no-xorq walk_nodes fallback re-implemented a depth-first traversal that already exists, nearly verbatim, in graph_utils.walk_nodes — and was never reached without xorq (its only callers are HAS_XORQ-short-circuited or xorq-gated). Replace it with a raising stub. replace_nodes stays a real delegation to ibis Node.replace since graph_utils.replace_nodes reaches it. Also trim three verbose backend-rebinding comments: the two HAS_XORQ short-circuits duplicated each other (now one points to the other), and the deferred-filter comment dropped the stale tracking-proxy aside. Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>
deepyaman
left a comment
There was a problem hiding this comment.
In addition to the below comments, want to clarify that should only update Ibis/Xorq capitalization in this PR if it's justifiably consistent with what exists across the codebase. Don't want to just capitalize whatever was introduced in this PR, if it's not in line with the rest of the codebase.
| pytest_args: "" | ||
| - name: no-xorq | ||
| sync: uv sync --all-extras --no-extra xorq --no-extra examples | ||
| pytest_args: --ignore=src/boring_semantic_layer/tests/integration |
There was a problem hiding this comment.
Is pytest_args the way to go for this? Should it not be markers based on whether Xorq is installed or something?
There was a problem hiding this comment.
Good instinct — markers/conditional skips are the right model, and the xorq-specific unit tests already do exactly that via importorskip/HAS_XORQ guards. This --ignore was narrower: it targets tests/integration/, whose BSL query modules (malloy_data/*.py) import xorq.api directly, so the whole directory genuinely requires xorq.
I moved that gate into the test tree — integration/conftest.py now sets collect_ignore_glob = ["*"] when import xorq fails, so the directory self-skips without xorq. Both CI legs now run a plain uv run pytest; pytest_args is gone. (8d1fbc6)
| - name: Verify import works | ||
| run: uv run python -c "import boring_semantic_layer; print('import OK')" | ||
|
|
There was a problem hiding this comment.
Why is this necessary? Was something like this there before?
There was a problem hiding this comment.
Yes — it predates this refactor: main's test-no-xorq job had a Verify import works without xorq step. It's a cheap smoke test that separates "the package does not import" from "a test failed" — especially meaningful for the no-xorq leg, whose whole purpose is proving BSL imports cleanly without xorq before the slower suite runs. The matrix just kept it. Happy to gate it to no-xorq only if you'd rather not have the (redundant) run on the full leg.
| 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), |
There was a problem hiding this comment.
Nit: Xorq-vendored Ibis? Should it not be hyphenated?
Also, in general I'd capitalize Ibis personally... I assume Xorq should be a proper name, the same. At the same time, there's also the consistency with existing comments and stuff, regardless of grammatical correctness, so use your judgement I guess.
There was a problem hiding this comment.
Hyphenated to xorq-vendored ibis — that's already the dominant form in the codebase (ops.py alone uses "xorq-vendored" 6×, plus nested_compile.py, mcp.py, server/api.py, …). Kept both lowercase deliberately: the codebase consistently lowercases xorq/ibis mid-sentence (package names) and only capitalizes at sentence start, so capitalizing here would break consistency rather than improve it. Applied the same fix to the two sibling pushdown test files that share the string. (b649f34)
| def walk_nodes(*args, **kwargs): | ||
| # Reachable only with xorq (from_ibis-wrapped graphs); without it the | ||
| # plain-ibis paths use graph_utils.walk_nodes and every other caller is | ||
| # xorq-gated, so this should never run. | ||
| raise ImportError( | ||
| "xorq is required for walk_nodes; " | ||
| "install with: pip install 'boring-semantic-layer[xorq]'" | ||
| ) | ||
|
|
There was a problem hiding this comment.
If this is not reachable, then why is it necessary? Is there a good justification?
There was a problem hiding this comment.
It keeps the shim's symbol surface symmetric: every name the xorq branch exports is importable from the no-xorq branch too, so the function-local from ._xorq import ..., walk_nodes at the call sites in ops.py/reconstruct.py still resolves. You're right the body is unreachable without xorq — those callers all bail on HAS_XORQ first — so the raise is defense-in-depth: a clear, actionable error if some future un-gated caller reaches it, instead of a cryptic cannot import name. Reworded the comment to say that rather than just "should never run." (b649f34)
The integration tests load BSL query modules that import xorq directly, so the whole directory needs xorq. Move that gate into the test tree (collect_ignore_glob in the integration conftest) instead of a --ignore CLI flag, so both CI legs run a plain `pytest` and the condition lives with the tests that require it. Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>
Use the dominant 'xorq-vendored ibis' compound-adjective form (lowercase, per codebase convention) in the projection-pushdown test comments. Reword the no-xorq walk_nodes stub comment to explain why it exists (symbol-surface symmetry for the gated function-local imports) rather than just that it never runs. Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>
Resolves #270 (and progresses towards #140)
What this does
Moves
xorqfrom a core dependency to an optional[xorq]extra, sopip install boring-semantic-layerworks without it. Core querying, YAML round-trips, and named profiles all continue to function against plain ibis backends.Key changes
_xorq.py— dual-source shimWraps all xorq imports in a
try/except ImportErrorblock. When xorq is absent, falls back to the equivalent plainibis-frameworksymbols. Exposes aHAS_XORQflag used by call sites to gate xorq-only code paths. Provides pure-Python fallbacks forto_node,walk_nodes,replace_nodes,from_ibis(identity), and amap_ibisstub that preserves theregister()interface without doing anything.profile.pyGates the
XorqProfilelookup onHAS_XORQ; skipsxorq_dirdiscovery when xorq is absent. Extracts_connect_plain_ibis()to avoid duplicating the connection logic across both the xorq-absent path and the existing xorq-AssertionErrorfallback. Adds_expand_env_vars(), which raisesProfileErroron undefined${VAR}references —os.path.expandvarssilently leaves them in place, so without xorqdatabase: ${MISSING}would create a DuckDB file literally named${MISSING}. This matches xorq's strict behavior and only manifests on the new plain-ibis path.ops.py/query.py— no-xorq parityShort-circuits
_rebind_to_canonical_backendandSemanticJoinOpbackend rebinding whenHAS_XORQis false: without xorqfrom_ibis()is an identity, so there is nothing to rebind, and returning the inputs unchanged avoids the shim's plain-ibis walk/replace rebuilding an equal-but-distinct expression. Marks dict/string filters with__bsl_deferred_resolution__so the dimension-only source-table shortcut opts out of static column introspection consistently — without xorq the tracking proxy would otherwise spuriously appear to succeed.serialization/__init__.pyandserialization/tag_handler.pyGuards
from_taggedwith the sametry_import_xorq()+ImportErrorpattern already used byto_tagged. Setsbsl_tag_handler = Nonewhen xorq is absent (the entry point is only loaded by xorq at runtime, so this is safe).pyproject.tomlxorq>=0.3.25moves to an[xorq]optional extra;examplesnow pullsxorq[duckdb]>=0.3.25. Thedevextra drops its self-referentialboring-semantic-layer[...]bundle and lists the LLM provider clients and developer tooling directly.CI
The
testjob is now a matrix over two legs:full(uv sync --all-extras) andno-xorq(--all-extras --no-extra xorq --no-extra examples, ignoring the integration suite). The no-xorq leg verifies xorq is genuinely optional — its tests skip, not error. Examples, docs, and skills (which need Node) move to a dedicatedbuildjob. Release gates (deploy-docs,release-pypi) now require bothtestandbuild.Tests
Xorq-only tests standardize on
pytest.importorskip("xorq")/ a top-levelimport xorqso the skip fires before any BSL import. Flavor-sensitive tests and the demo adopt thexibisshim idiom for composed expressions. The projection-pushdown xfail is clarified.test_dependency_groups.pyis updated for the new extra layout.