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
132 changes: 66 additions & 66 deletions slayer/api/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import logging
from importlib.metadata import PackageNotFoundError, version as _pkg_version
from typing import Any, Dict, List, Optional, Union
from typing import Any

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, ConfigDict, Field
Expand Down Expand Up @@ -32,34 +32,34 @@ class QueryRequest(BaseModel):
# to pass through to SlayerQuery's pre-validate hook.
model_config = ConfigDict(extra="allow")

name: Optional[str] = None # Run-by-name: backing query for a query-backed model
name: str | None = None # Run-by-name: backing query for a query-backed model
# ``source_model`` accepts a string (stored model name) or a dict
# — the dict form is an inline ``ModelExtension`` (``{"source_name":
# "<model>", "columns": [...], "joins": [...]}``) or an inline
# ``SlayerModel`` (``{"name": "...", "sql_table": "...", "data_source":
# "...", "columns": [...]}``). The full polymorphism is handled by
# ``SlayerQuery.model_validate`` downstream.
source_model: Optional[Union[str, Dict[str, Any]]] = None
source_model: str | dict[str, Any] | None = None
# ``measures`` and ``dimensions`` accept bare strings as a shorthand,
# mirroring the Python API: ``"*:count"`` is lifted to
# ``{"formula": "*:count"}``, ``"status"`` to ``{"name": "status"}``.
# ``SlayerQuery``'s before-validators (``_coerce_measures`` /
# ``_coerce_dimensions``) do the actual lifting downstream.
measures: Optional[List[Union[str, Dict[str, Any]]]] = None
dimensions: Optional[List[Union[str, Dict[str, Any]]]] = None
time_dimensions: Optional[List[Dict[str, Any]]] = None
filters: Optional[List[str]] = None
order: Optional[List[Dict[str, Any]]] = None
limit: Optional[int] = None
offset: Optional[int] = None
whole_periods_only: Optional[bool] = None
measures: list[str | dict[str, Any]] | None = None
dimensions: list[str | dict[str, Any]] | None = None
time_dimensions: list[dict[str, Any]] | None = None
filters: list[str] | None = None
order: list[dict[str, Any]] | None = None
limit: int | None = None
offset: int | None = None
whole_periods_only: bool | None = None
# DEV-1543: opt out of the dim-only auto-dedup GROUP BY. Default
# (``None`` here) keeps the v3 SlayerQuery default (``True``). Set
# ``False`` to emit raw rows.
distinct_dimension_values: Optional[bool] = None
dry_run: Optional[bool] = None
explain: Optional[bool] = None
variables: Optional[Dict[str, Any]] = None
distinct_dimension_values: bool | None = None
dry_run: bool | None = None
explain: bool | None = None
variables: dict[str, Any] | None = None


class QueryListRequest(BaseModel):
Expand All @@ -74,39 +74,39 @@ class QueryListRequest(BaseModel):

model_config = ConfigDict(extra="forbid")

queries: List[Dict[str, Any]]
variables: Optional[Dict[str, Any]] = None
dry_run: Optional[bool] = None
explain: Optional[bool] = None
queries: list[dict[str, Any]]
variables: dict[str, Any] | None = None
dry_run: bool | None = None
explain: bool | None = None


class FieldMetadataResponse(BaseModel):
label: Optional[str] = None
format: Optional[NumberFormat] = None
label: str | None = None
format: NumberFormat | None = None


class AttributesResponse(BaseModel):
dimensions: Dict[str, FieldMetadataResponse] = {}
measures: Dict[str, FieldMetadataResponse] = {}
dimensions: dict[str, FieldMetadataResponse] = {}
measures: dict[str, FieldMetadataResponse] = {}


class QueryResponse(BaseModel):
data: List[Dict[str, Any]]
data: list[dict[str, Any]]
row_count: int
columns: List[str]
sql: Optional[str] = None
attributes: Optional[AttributesResponse] = None
columns: list[str]
sql: str | None = None
attributes: AttributesResponse | None = None


class IngestRequest(BaseModel):
datasource: str
include_tables: Optional[List[str]] = None
exclude_tables: Optional[List[str]] = None
schema_name: Optional[str] = None
include_tables: list[str] | None = None
exclude_tables: list[str] | None = None
schema_name: str | None = None


class ValidateModelsRequest(BaseModel):
data_source: Optional[str] = None
data_source: str | None = None


class DatasourcePriorityRequest(BaseModel):
Expand All @@ -115,7 +115,7 @@ class DatasourcePriorityRequest(BaseModel):
shape and FastAPI rejects mistyped payloads with 422 instead of
silently coercing them downstream.
"""
priority: List[str] = []
priority: list[str] = []


class SaveMemoryRequest(BaseModel):
Expand All @@ -140,8 +140,8 @@ class SaveMemoryRequest(BaseModel):

learning: str
linked_entities: Any
id: Optional[str] = None
description: Optional[str] = None
id: str | None = None
description: str | None = None


class SearchRequest(BaseModel):
Expand All @@ -160,12 +160,12 @@ class SearchRequest(BaseModel):

model_config = ConfigDict(extra="forbid")

entities: Optional[List[str]] = None
query: Optional[Any] = None
question: Optional[str] = None
datasource: Optional[str] = None
entities: list[str] | None = None
query: Any | None = None
question: str | None = None
datasource: str | None = None
max_results: int = Field(default=10, ge=1)
cypher_filter: Optional[str] = None
cypher_filter: str | None = None
compact: bool = True


Expand All @@ -181,8 +181,8 @@ class InspectRequest(BaseModel):
format: str = "markdown"
num_rows: int = 3
show_sql: bool = False
sections: Optional[List[str]] = None
descriptions_max_chars: Optional[int] = None
sections: list[str] | None = None
descriptions_max_chars: int | None = None


def _slayer_version() -> str:
Expand Down Expand Up @@ -218,7 +218,7 @@ def create_app( # NOSONAR(S3776) — FastAPI route-handler factory; complexity
app.mount("/mcp", mcp_app)

@app.get("/health")
async def health() -> Dict[str, str]:
async def health() -> dict[str, str]:
return {"status": "ok"}

@app.post(
Expand All @@ -237,7 +237,7 @@ async def health() -> Dict[str, str]:
},
)
async def query(
request: Union[QueryRequest, QueryListRequest],
request: QueryRequest | QueryListRequest,
) -> QueryResponse:
try:
# Multi-stage DAG: body is ``{"queries": [...], "variables": ...,
Expand Down Expand Up @@ -313,7 +313,7 @@ async def query(
)
attrs = result.attributes

def _convert_meta(d: dict) -> Dict[str, FieldMetadataResponse]:
def _convert_meta(d: dict) -> dict[str, FieldMetadataResponse]:
return {k: FieldMetadataResponse(label=v.label, format=v.format) for k, v in d.items()}

attributes = None
Expand Down Expand Up @@ -348,8 +348,8 @@ def _convert_meta(d: dict) -> Dict[str, FieldMetadataResponse]:

@app.get("/models")
async def list_models(
data_source: Optional[str] = None,
) -> List[Dict[str, Any]]:
data_source: str | None = None,
) -> list[dict[str, Any]]:
identities = await storage._list_all_model_identities()
result = []
for ds_name, name in identities:
Expand All @@ -358,7 +358,7 @@ async def list_models(
model = await storage.get_model(name, data_source=ds_name)
if model is None or model.hidden:
continue
entry: Dict[str, Any] = {"name": name, "data_source": ds_name}
entry: dict[str, Any] = {"name": name, "data_source": ds_name}
if model.description:
entry["description"] = model.description
result.append(entry)
Expand All @@ -378,8 +378,8 @@ async def list_models(
)
async def get_model(
name: str,
data_source: Optional[str] = None,
) -> Dict[str, Any]:
data_source: str | None = None,
) -> dict[str, Any]:
try:
model = await storage.get_model(name, data_source=data_source)
except AmbiguousModelError as exc:
Expand All @@ -401,7 +401,7 @@ async def get_model(
"/models",
responses={400: {"description": "Model failed validation (e.g. user-supplied cache fields on a query-backed model, save-time SQL generation failure)."}},
)
async def create_model(model: SlayerModel) -> Dict[str, str]:
async def create_model(model: SlayerModel) -> dict[str, str]:
# Route through engine.save_model so query-backed models get cache
# populated (and user-supplied cache fields are rejected).
try:
Expand All @@ -414,7 +414,7 @@ async def create_model(model: SlayerModel) -> Dict[str, str]:
"/models/{name}",
responses={400: {"description": "Body name does not match path name, or model failed validation."}},
)
async def update_model(name: str, model: SlayerModel) -> Dict[str, str]:
async def update_model(name: str, model: SlayerModel) -> dict[str, str]:
if model.name != name:
raise HTTPException(
status_code=400,
Expand All @@ -440,8 +440,8 @@ async def update_model(name: str, model: SlayerModel) -> Dict[str, str]:
)
async def delete_model(
name: str,
data_source: Optional[str] = None,
) -> Dict[str, Any]:
data_source: str | None = None,
) -> dict[str, Any]:
try:
deleted = await storage.delete_model(name, data_source=data_source)
except AmbiguousModelError as exc:
Expand All @@ -457,7 +457,7 @@ async def delete_model(
return {"status": "deleted", "name": name}

@app.get("/datasources/priority")
async def get_datasource_priority() -> Dict[str, List[str]]:
async def get_datasource_priority() -> dict[str, list[str]]:
return {"priority": await storage.get_datasource_priority()}

@app.put(
Expand All @@ -471,7 +471,7 @@ async def get_datasource_priority() -> Dict[str, List[str]]:
}
},
)
async def put_datasource_priority(body: DatasourcePriorityRequest) -> Dict[str, Any]:
async def put_datasource_priority(body: DatasourcePriorityRequest) -> dict[str, Any]:
priority = list(body.priority)
try:
await storage.set_datasource_priority(priority)
Expand All @@ -480,18 +480,18 @@ async def put_datasource_priority(body: DatasourcePriorityRequest) -> Dict[str,
return {"status": "ok", "priority": priority}

@app.get("/datasources")
async def list_datasources() -> List[Dict[str, Any]]:
async def list_datasources() -> list[dict[str, Any]]:
result = []
for name in await storage.list_datasources():
ds = await storage.get_datasource(name)
entry: Dict[str, Any] = {"name": name}
entry: dict[str, Any] = {"name": name}
if ds:
entry["type"] = ds.type
result.append(entry)
return result

@app.get("/datasources/{name}")
async def get_datasource(name: str) -> Dict[str, Any]:
async def get_datasource(name: str) -> dict[str, Any]:
ds = await storage.get_datasource(name)
if ds is None:
raise HTTPException(
Expand All @@ -505,12 +505,12 @@ async def get_datasource(name: str) -> Dict[str, Any]:
return data

@app.post("/datasources")
async def create_datasource(datasource: DatasourceConfig) -> Dict[str, str]:
async def create_datasource(datasource: DatasourceConfig) -> dict[str, str]:
await storage.save_datasource(datasource)
return {"status": "created", "name": datasource.name}

@app.delete("/datasources/{name}")
async def delete_datasource(name: str) -> Dict[str, Any]:
async def delete_datasource(name: str) -> dict[str, Any]:
deleted = await storage.delete_datasource(name)
if not deleted:
raise HTTPException(
Expand All @@ -533,7 +533,7 @@ async def delete_datasource(name: str) -> Dict[str, Any]:
)
async def validate_models_endpoint(
request: ValidateModelsRequest,
) -> List[Dict[str, Any]]:
) -> list[dict[str, Any]]:
"""Diff persisted SlayerModels against live DB schemas. Read-only."""
if request.data_source is not None:
ds_check = await storage.get_datasource(request.data_source)
Expand Down Expand Up @@ -572,7 +572,7 @@ async def validate_models_endpoint(
},
},
)
async def ingest(request: IngestRequest) -> Dict[str, Any]:
async def ingest(request: IngestRequest) -> dict[str, Any]:
# Strip newlines from the user-controlled datasource name before it
# reaches the log or the response detail (S5145 — log-injection
# surface). The ds value already round-tripped through Pydantic so
Expand Down Expand Up @@ -651,7 +651,7 @@ async def ingest(request: IngestRequest) -> Dict[str, Any]:
}
},
)
async def save_memory(request: SaveMemoryRequest) -> Dict[str, Any]:
async def save_memory(request: SaveMemoryRequest) -> dict[str, Any]:
try:
response = await memory_service.save_memory(
learning=request.learning,
Expand All @@ -674,7 +674,7 @@ async def save_memory(request: SaveMemoryRequest) -> Dict[str, Any]:
404: {"description": "Memory not found."},
},
)
async def delete_memory(memory_id: str) -> Dict[str, Any]:
async def delete_memory(memory_id: str) -> dict[str, Any]:
try:
response = await memory_service.forget_memory(identifier=memory_id)
except MemoryNotFoundError as exc:
Expand All @@ -700,7 +700,7 @@ async def delete_memory(memory_id: str) -> Dict[str, Any]:
}
},
)
async def search(request: SearchRequest) -> Dict[str, Any]:
async def search(request: SearchRequest) -> dict[str, Any]:
try:
response = await search_service.search(
entities=request.entities,
Expand Down Expand Up @@ -728,7 +728,7 @@ async def search(request: SearchRequest) -> Dict[str, Any]:
}
},
)
async def inspect(request: InspectRequest) -> Dict[str, Any]:
async def inspect(request: InspectRequest) -> dict[str, Any]:
try:
result = await inspect_service.inspect(
reference=request.reference,
Expand Down
3 changes: 2 additions & 1 deletion slayer/async_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@

import asyncio
import concurrent.futures
from typing import Any, Coroutine, TypeVar
from typing import Any, TypeVar
from collections.abc import Coroutine

T = TypeVar("T")

Expand Down
Loading
Loading