Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,7 @@ examples/*.local.yml

# Eval transcripts
.eval/

# Local working files (not part of the repo)
git-catalogs/
/flights.csv
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"attrs>=25.3.0",
"ibis-framework>=11.0.0",
"ibis-framework>=11.0.0,<12", # deep Relation/Deferred internals coupling; expand after matrix-testing a new major
"packaging",
"pyyaml>=6.0",
"returns>=0.26.0",
Expand Down Expand Up @@ -55,7 +55,7 @@ server = [
# `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",
"ibis-framework[duckdb]>=11.0.0,<12",
"pytest",
"pytest-xdist",
"pandas>=2.3.0",
Expand Down
4 changes: 2 additions & 2 deletions src/boring_semantic_layer/agents/backends/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from pydantic import Field
from pydantic.functional_validators import BeforeValidator

from ...query import _find_time_dimension
from ...query import find_time_dimension
from ..utils.chart_handler import generate_chart_with_data
from ..utils.prompts import load_prompt

Expand Down Expand Up @@ -109,7 +109,7 @@ def get_time_range(model_name: str) -> Mapping[str, Any]:

model = self.models[model_name]
all_dims = list(model.dimensions)
time_dim_name = _find_time_dimension(model, all_dims)
time_dim_name = find_time_dimension(model, all_dims)

if not time_dim_name:
raise ValueError(f"Model {model_name} has no time dimension")
Expand Down
11 changes: 5 additions & 6 deletions src/boring_semantic_layer/chart/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,6 @@ def detect_time_dimension(
Returns:
Name of time dimension if found, None otherwise
"""
from ..ops import _find_all_root_models, _get_merged_fields

# Get root models and dimension dictionary
aggregate_op = semantic_aggregate.op()

Expand All @@ -196,12 +194,13 @@ def detect_time_dimension(
return detect_time_dimension_from_dtype(df, dimensions)
return None

all_roots = _find_all_root_models(aggregate_op.source)
if not all_roots:
# The op metadata protocol already merges (and prefixes) dimensions
# across joins — extras stay on the public surface.
try:
dims_dict = aggregate_op.source.get_dimensions()
except AttributeError:
return None

dims_dict = _get_merged_fields(all_roots, "dimensions")

# Strategy 1: Check metadata
for dim_name in dimensions:
if dim_name in dims_dict:
Expand Down
4 changes: 2 additions & 2 deletions src/boring_semantic_layer/ops/_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -1267,7 +1267,7 @@ def _build_select_or_aggregate(
return tbl


class SemanticProjectOp(Relation):
class SemanticProjectOp(_SourcePassThroughOp, Relation):
source: Relation
fields: tuple[str, ...]

Expand Down Expand Up @@ -1320,7 +1320,7 @@ def to_untagged(self):
return _build_select_or_aggregate(active_tbl, dim_exprs, meas_exprs, raw_exprs)


class SemanticGroupByOp(Relation):
class SemanticGroupByOp(_SourcePassThroughOp, Relation):
source: Relation
keys: tuple[str, ...]

Expand Down
6 changes: 3 additions & 3 deletions src/boring_semantic_layer/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ def _is_time_dimension(dims_dict: dict[str, Any], dim_name: str) -> bool:
return dim_name in dims_dict and dims_dict[dim_name].is_time_dimension


def _find_time_dimension(semantic_table: Any, dimensions: list[str]) -> str | None:
def find_time_dimension(semantic_table: Any, dimensions: list[str]) -> str | None:
"""
Find the first time dimension in the query dimensions list.

Expand Down Expand Up @@ -611,7 +611,7 @@ def compare_periods(
[resolved_time_dimension], known_dimensions, expected_prefix=model_name
)[0]
else:
resolved_time_dimension = _find_time_dimension(
resolved_time_dimension = find_time_dimension(
semantic_table, dimensions
) or _find_any_time_dimension(semantic_table)

Expand Down Expand Up @@ -841,7 +841,7 @@ def query(

# Step 0: Add time_range as a filter if specified
if time_range:
time_dim_name = _find_time_dimension(result, dimensions)
time_dim_name = find_time_dimension(result, dimensions)
if not time_dim_name:
raise ValueError(
"time_range filter requires a time dimension in the query dimensions. "
Expand Down
28 changes: 23 additions & 5 deletions src/boring_semantic_layer/server/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,7 @@
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field, model_validator

from boring_semantic_layer.agents.utils.chart_handler import generate_chart_with_data
from boring_semantic_layer.query import _find_time_dimension
from boring_semantic_layer.query import find_time_dimension

from .loader import load_models

Expand Down Expand Up @@ -89,6 +88,25 @@ def _check_grain_fields(self) -> ComparePeriodsRequest:
return self


def _generate_chart_with_data():
"""Resolve the chart generator from the agents extra at call time.

The server extra does not depend on the agents extra; only the chart
endpoints need it, and they say so clearly when it is missing.
"""
try:
from boring_semantic_layer.agents.utils.chart_handler import (
generate_chart_with_data,
)
except ImportError as exc:
raise HTTPException(
status_code=501,
detail="Chart generation requires the agents extra: "
"pip install 'boring-semantic-layer[agent]'",
) from exc
return generate_chart_with_data


def _default_cors_origins() -> list[str]:
raw = os.environ.get("BSL_CORS_ORIGINS")
if not raw:
Expand Down Expand Up @@ -146,7 +164,7 @@ def _build_model_response(model: Any) -> dict[str, Any]:

def _get_time_range_response(model: Any, model_name: str) -> dict[str, str]:
dimensions = model.get_dimensions()
time_dim_name = _find_time_dimension(model, list(dimensions))
time_dim_name = find_time_dimension(model, list(dimensions))
if not time_dim_name:
raise HTTPException(status_code=400, detail=f"Model '{model_name}' has no time dimension")

Expand Down Expand Up @@ -372,7 +390,7 @@ def query_model(payload: QueryRequest, request: Request) -> dict[str, Any]:
time_range=payload.time_range,
)
response = json.loads(
generate_chart_with_data(
_generate_chart_with_data()(
query_result,
get_records=payload.get_records,
records_limit=payload.records_limit,
Expand Down Expand Up @@ -404,7 +422,7 @@ def compare_periods(payload: ComparePeriodsRequest, request: Request) -> dict[st
limit=payload.limit,
)
response = json.loads(
generate_chart_with_data(
_generate_chart_with_data()(
query_result,
get_records=payload.get_records,
records_limit=payload.records_limit,
Expand Down
111 changes: 111 additions & 0 deletions src/boring_semantic_layer/tests/test_import_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,3 +194,114 @@ def test_scc_allowlist_is_ratcheted_down():
"Module(s) no longer in the import SCC — lock in the progress by "
f"removing them from KNOWN_SCC_MEMBERS: {sorted(stale)}"
)


# ---------------------------------------------------------------------------
# Layer contract
# ---------------------------------------------------------------------------

#: Module-prefix -> layer. Edges must point to an equal or lower layer.
#: Within-layer edges are unrestricted (global acyclicity is enforced above).
_LAYERS: dict[str, int] = {
# 0: primitives — import nothing from the package but each other
"_xorq": 0,
"errors": 0,
"fieldref": 0,
"io": 0,
"safe_eval": 0,
"nested_access": 0,
"predicate": 0,
"config": 0,
# 1: analysis/util modules over primitives
"graph_utils": 1,
"measure_scope": 1,
"calc_analyzer": 1,
"nested_compile": 1,
"projection_utils": 1,
"profile": 1,
# 2: compilers-of-expressions
"calc_compiler": 2,
"convert": 2,
# 3: the semantic ops + their compiler
"ops": 3,
# 4: user-facing expressions and repr
"expr": 4,
"format": 4,
# 5: sugar and orchestration over expressions
"api": 5,
"query": 5,
"yaml": 5,
# 6: serialization of everything below
"serialization": 6,
# 7: optional extras and the root facade
"chart": 7,
"agents": 7,
"server": 7,
"<root>": 7,
}

_EXTRAS_PREFIXES = ("chart", "agents", "server")

#: Underscore-prefixed core modules extras may import: the ibis-flavor shim
#: is the designated package-wide import point for xorq symbols.
_EXTRAS_PRIVATE_MODULE_ALLOWLIST = frozenset({"_xorq"})


def _layer_of(module: str) -> int:
short = module.removeprefix(PKG_NAME + ".") if module != PKG_NAME else "<root>"
head = short.split(".", 1)[0]
if head not in _LAYERS:
raise AssertionError(
f"Module {short!r} is not assigned to a layer — add it to _LAYERS "
"in test_import_graph.py when introducing a new top-level module."
)
return _LAYERS[head]


def test_layer_contract():
"""Every import edge points downward (or stays within its layer)."""
modules = _discover_modules()
edges = _build_edges(modules)
violations = sorted(
f"{a.removeprefix(PKG_NAME + '.')} (L{_layer_of(a)}) -> "
f"{b.removeprefix(PKG_NAME + '.')} (L{_layer_of(b)})"
for a, b in edges
if _layer_of(b) > _layer_of(a)
)
assert not violations, "Upward imports break the layer contract:\n" + "\n".join(violations)


def test_extras_do_not_import_core_privates():
"""chart/agents/server must use only the core public surface."""
modules = _discover_modules()
violations: list[str] = []
for mod, path in modules.items():
short = mod.removeprefix(PKG_NAME + ".")
if not short.startswith(_EXTRAS_PREFIXES):
continue
tree = ast.parse(path.read_text())
for node in ast.walk(tree):
if not isinstance(node, ast.ImportFrom):
continue
target = node.module or ""
if node.level == 0 and not (target == PKG_NAME or target.startswith(PKG_NAME + ".")):
continue # stdlib / third-party import
absolute = target.removeprefix(PKG_NAME + ".") if target else ""
# Imports within the extra's own subpackage are unrestricted.
if node.level and node.level == 1:
continue
if absolute.startswith(_EXTRAS_PREFIXES):
continue
parts = [p for p in absolute.split(".") if p]
private_module = any(
p.startswith("_") and p not in _EXTRAS_PRIVATE_MODULE_ALLOWLIST for p in parts
)
private_names = [a.name for a in node.names if a.name.startswith("_") and a.name != "_"]
if private_module or private_names:
violations.append(
f"{short}: from {target or '.' * node.level} import "
f"{', '.join(a.name for a in node.names)}"
)
assert not violations, "Extras must import only the core public surface:\n" + "\n".join(
sorted(violations)
)
4 changes: 2 additions & 2 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading