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
15 changes: 11 additions & 4 deletions sidemantic/core/registry.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"""Global registry for auto-registration of models and measures."""

from contextvars import ContextVar
from contextvars import ContextVar, Token
from typing import TYPE_CHECKING

from sidemantic.validation import MetricValidationError

if TYPE_CHECKING:
from .semantic_layer import SemanticLayer

Expand All @@ -15,9 +17,14 @@ def get_current_layer() -> "SemanticLayer | None":
return _current_layer.get()


def set_current_layer(layer: "SemanticLayer | None"):
def set_current_layer(layer: "SemanticLayer | None") -> Token:
"""Set the current semantic layer context."""
_current_layer.set(layer)
return _current_layer.set(layer)


def reset_current_layer(token: Token) -> None:
"""Restore the layer that was active before ``set_current_layer``."""
_current_layer.reset(token)


def auto_register_model(model):
Expand Down Expand Up @@ -48,7 +55,7 @@ def auto_register_metric(metric):
if not metric.agg or metric.type in ("derived", "ratio"):
try:
layer.add_metric(metric)
except Exception:
except (ValueError, MetricValidationError):
# Best-effort: metric might already exist or validation might fail during init
import logging

Expand Down
37 changes: 30 additions & 7 deletions sidemantic/core/semantic_layer.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import os
from collections.abc import Callable
from contextvars import ContextVar, Token
from pathlib import Path

import yaml
Expand Down Expand Up @@ -269,24 +270,46 @@ def __init__(
else:
raise TypeError(f"connection must be a string URL or BaseDatabaseAdapter instance, got {type(connection)}")

# Set as current layer for auto-registration
self._registration_token = None
self._context_registration_tokens: ContextVar[tuple[Token, ...]] = ContextVar(
f"semantic_layer_registration_tokens_{id(self)}", default=()
)
if auto_register:
from .registry import set_current_layer

set_current_layer(self)
self._registration_token = set_current_layer(self)

def __enter__(self):
"""Context manager entry - set as current layer."""
from .registry import set_current_layer

set_current_layer(self)
token = set_current_layer(self)
tokens = self._context_registration_tokens.get()
self._context_registration_tokens.set((*tokens, token))
return self

def __exit__(self, exc_type, exc_val, exc_tb):
"""Context manager exit - clear current layer and close adapter."""
from .registry import set_current_layer

set_current_layer(None)
"""Context manager exit - restore the prior layer and close adapter."""
from .registry import get_current_layer, reset_current_layer

tokens = self._context_registration_tokens.get()
if tokens:
token = tokens[-1]
remaining_tokens = tokens[:-1]
self._context_registration_tokens.set(remaining_tokens)
reset_current_layer(token)

# An inline ``with SemanticLayer(auto_register=True)`` should still
# restore the layer that preceded construction. A copied async
# context cannot reset the constructor's token, so leave that token
# for its owning context instead of raising from ``__exit__``.
if not remaining_tokens and self._registration_token is not None and get_current_layer() is self:
try:
reset_current_layer(self._registration_token)
except ValueError:
pass
else:
self._registration_token = None
if hasattr(self.adapter, "close"):
self.adapter.close()

Expand Down
56 changes: 56 additions & 0 deletions tests/test_metric_auto_registration.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,62 @@
"""Tests for metric auto-registration."""

import asyncio

import pytest

from sidemantic import Dimension, Metric, Model, SemanticLayer
from sidemantic.core.registry import get_current_layer


def test_explicit_registration_mode_has_no_ambient_side_effect():
layer = SemanticLayer(auto_register=False)
Model(name="orders", table="orders")

assert layer.list_models() == []
assert get_current_layer() is None


def test_nested_registration_context_restores_outer_layer():
outer = SemanticLayer(auto_register=False)
inner = SemanticLayer(auto_register=False)

with outer:
assert get_current_layer() is outer
with inner:
assert get_current_layer() is inner
assert get_current_layer() is outer

assert get_current_layer() is None


def test_auto_registered_layer_context_is_safe_in_copied_async_context():
layer = SemanticLayer(auto_register=True)

async def use_layer_in_child_context():
assert get_current_layer() is layer
with layer:
assert get_current_layer() is layer
await asyncio.sleep(0)
assert get_current_layer() is layer

asyncio.run(use_layer_in_child_context())

assert get_current_layer() is layer
with layer:
assert get_current_layer() is layer
assert get_current_layer() is None


def test_auto_registration_does_not_hide_unexpected_failures(monkeypatch):
layer = SemanticLayer(auto_register=True)

def fail_unexpectedly(_metric):
raise RuntimeError("implementation bug")

monkeypatch.setattr(layer, "add_metric", fail_unexpectedly)

with pytest.raises(RuntimeError, match="implementation bug"):
Metric(name="broken", type="derived", sql="orders.revenue")


def test_standalone_metric_auto_registers_with_context_manager():
Expand Down
Loading