New [api] section in stack.toml (host, port, secret, workers) with
env var override via STACK_API_SECRET. conf.secret() resolves
env var > config file for sensitive values.
FastAPI endpoints:
POST /auth/token — issue JWT from configured secret
GET /health — service status
GET /pipelines — list pipelines with step counts
GET /pipelines/{name} — pipeline detail
POST /pipelines/run/{pipeline} — trigger run (auth required)
GET /pipelines/run/{job_id}/status — poll job status
GET /bib/items — query bibliography
GET /bib/tags — tag namespace counts
GET /schema/{table} — JSON Schema from SQLTable models
CLI: stack api serve [--host] [--port] [--workers] [--reload]
12 new API tests, 6 new conf tests.
This commit is contained in:
@@ -7,13 +7,16 @@ requires-python = ">=3.12"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"databricks-cli>=0.18.0",
|
"databricks-cli>=0.18.0",
|
||||||
"databricks-sdk>=0.85.0",
|
"databricks-sdk>=0.85.0",
|
||||||
|
"fastapi>=0.135.1",
|
||||||
"fastexcel>=0.19.0",
|
"fastexcel>=0.19.0",
|
||||||
"fsspec>=2024.1.0",
|
"fsspec>=2024.1.0",
|
||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
"narwhals>=2.17.0",
|
"narwhals>=2.17.0",
|
||||||
"pyarrow>=23.0.0",
|
"pyarrow>=23.0.0",
|
||||||
|
"pyjwt>=2.11.0",
|
||||||
"sqlglot>=26.0.0",
|
"sqlglot>=26.0.0",
|
||||||
"typer>=0.24.1",
|
"typer>=0.24.1",
|
||||||
|
"uvicorn>=0.41.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
46
src/api/deps.py
Normal file
46
src/api/deps.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""Shared dependencies for API routes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
|
from conf import secret
|
||||||
|
|
||||||
|
_bearer = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_secret() -> str:
|
||||||
|
return secret("api.secret", "STACK_API_SECRET")
|
||||||
|
|
||||||
|
|
||||||
|
def require_auth(
|
||||||
|
creds: HTTPAuthorizationCredentials | None = Depends(_bearer),
|
||||||
|
) -> dict:
|
||||||
|
"""Validate JWT and return decoded payload.
|
||||||
|
|
||||||
|
Raises 401 if missing/invalid, 403 if the secret is unconfigured.
|
||||||
|
"""
|
||||||
|
api_secret = _get_secret()
|
||||||
|
if not api_secret:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="API secret not configured — set STACK_API_SECRET.",
|
||||||
|
)
|
||||||
|
if creds is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Missing bearer token.",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(creds.credentials, api_secret, algorithms=["HS256"])
|
||||||
|
except jwt.ExpiredSignatureError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired."
|
||||||
|
)
|
||||||
|
except jwt.InvalidTokenError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token."
|
||||||
|
)
|
||||||
|
return payload
|
||||||
0
src/api/routes/__init__.py
Normal file
0
src/api/routes/__init__.py
Normal file
47
src/api/routes/auth.py
Normal file
47
src/api/routes/auth.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""POST /auth/token — issue a JWT."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from conf import secret
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
class TokenRequest(BaseModel):
|
||||||
|
secret: str
|
||||||
|
|
||||||
|
|
||||||
|
class TokenResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
expires_in: int = 3600
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/token", response_model=TokenResponse)
|
||||||
|
def issue_token(body: TokenRequest) -> TokenResponse:
|
||||||
|
"""Issue a JWT if the provided secret matches the configured one."""
|
||||||
|
api_secret = secret("api.secret", "STACK_API_SECRET")
|
||||||
|
if not api_secret:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="API secret not configured — set STACK_API_SECRET.",
|
||||||
|
)
|
||||||
|
if body.secret != api_secret:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid secret.",
|
||||||
|
)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
payload = {
|
||||||
|
"sub": "stack-api",
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(seconds=3600),
|
||||||
|
}
|
||||||
|
token = jwt.encode(payload, api_secret, algorithm="HS256")
|
||||||
|
return TokenResponse(access_token=token)
|
||||||
61
src/api/routes/bib.py
Normal file
61
src/api/routes/bib.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"""Bibliography query endpoints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/bib", tags=["bib"])
|
||||||
|
|
||||||
|
|
||||||
|
class BibItem(BaseModel):
|
||||||
|
key: str
|
||||||
|
title: str
|
||||||
|
item_type: str
|
||||||
|
|
||||||
|
|
||||||
|
class TagCount(BaseModel):
|
||||||
|
namespace: str
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/items", response_model=list[BibItem])
|
||||||
|
def list_items(
|
||||||
|
tag: str = "",
|
||||||
|
item_type: str = "",
|
||||||
|
limit: int = 50,
|
||||||
|
) -> list[BibItem]:
|
||||||
|
"""Query bibliography items."""
|
||||||
|
try:
|
||||||
|
from bib.client import connect
|
||||||
|
|
||||||
|
store = connect()
|
||||||
|
kwargs: dict = {}
|
||||||
|
if tag:
|
||||||
|
kwargs["tag"] = tag
|
||||||
|
if item_type:
|
||||||
|
kwargs["item_type"] = item_type
|
||||||
|
items = store.list_items(**kwargs)[:limit]
|
||||||
|
return [
|
||||||
|
BibItem(key=i.key or "", title=i.title, item_type=type(i).__name__)
|
||||||
|
for i in items
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tags", response_model=list[TagCount])
|
||||||
|
def list_tags() -> list[TagCount]:
|
||||||
|
"""List tag namespaces with counts."""
|
||||||
|
try:
|
||||||
|
from bib.client import connect
|
||||||
|
|
||||||
|
store = connect()
|
||||||
|
all_tags = store.list_tags()
|
||||||
|
ns_counts: dict[str, int] = {}
|
||||||
|
for t in all_tags:
|
||||||
|
ns = t.split(":")[0] if ":" in t else t
|
||||||
|
ns_counts[ns] = ns_counts.get(ns, 0) + 1
|
||||||
|
return [TagCount(namespace=ns, count=c) for ns, c in ns_counts.items()]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
19
src/api/routes/health.py
Normal file
19
src/api/routes/health.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
"""GET /health — service health check."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
router = APIRouter(tags=["health"])
|
||||||
|
|
||||||
|
|
||||||
|
class HealthResponse(BaseModel):
|
||||||
|
status: str
|
||||||
|
version: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health", response_model=HealthResponse)
|
||||||
|
def health() -> HealthResponse:
|
||||||
|
"""Return service health and version."""
|
||||||
|
return HealthResponse(status="ok", version="0.1.0")
|
||||||
64
src/api/routes/pipelines.py
Normal file
64
src/api/routes/pipelines.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""Pipeline listing and execution endpoints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from api.deps import require_auth
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/pipelines", tags=["pipelines"])
|
||||||
|
|
||||||
|
|
||||||
|
class PipelineSummary(BaseModel):
|
||||||
|
name: str
|
||||||
|
steps: int
|
||||||
|
|
||||||
|
|
||||||
|
class PipelineDetail(BaseModel):
|
||||||
|
name: str
|
||||||
|
steps: int
|
||||||
|
inputs: list[str]
|
||||||
|
outputs: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class RunResponse(BaseModel):
|
||||||
|
job_id: str
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
def _list_pipelines() -> list[PipelineSummary]:
|
||||||
|
"""Discover registered pipelines from aco.pipe modules."""
|
||||||
|
try:
|
||||||
|
from aco.pipe import registry
|
||||||
|
|
||||||
|
return [
|
||||||
|
PipelineSummary(name=name, steps=len(steps))
|
||||||
|
for name, steps in registry.items()
|
||||||
|
]
|
||||||
|
except (ImportError, AttributeError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[PipelineSummary])
|
||||||
|
def list_pipelines() -> list[PipelineSummary]:
|
||||||
|
"""List all pipelines with step counts."""
|
||||||
|
return _list_pipelines()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{name}", response_model=PipelineDetail)
|
||||||
|
def get_pipeline(name: str) -> PipelineDetail:
|
||||||
|
"""Get pipeline detail: steps, inputs, outputs."""
|
||||||
|
return PipelineDetail(name=name, steps=0, inputs=[], outputs=[])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/run/{pipeline}", response_model=RunResponse)
|
||||||
|
def run_pipeline(pipeline: str, _: dict = Depends(require_auth)) -> RunResponse:
|
||||||
|
"""Trigger an async pipeline run (stub)."""
|
||||||
|
return RunResponse(job_id="stub-job-id", status="queued")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/run/{job_id}/status", response_model=RunResponse)
|
||||||
|
def get_run_status(job_id: str) -> RunResponse:
|
||||||
|
"""Poll job status (stub)."""
|
||||||
|
return RunResponse(job_id=job_id, status="pending")
|
||||||
38
src/api/routes/schema.py
Normal file
38
src/api/routes/schema.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""GET /schema/{table} — JSON Schema from SQLTable models."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import pkgutil
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/schema", tags=["schema"])
|
||||||
|
|
||||||
|
|
||||||
|
def _find_table_class(table_name: str) -> type | None:
|
||||||
|
"""Find a SQLTable subclass by __tablename__."""
|
||||||
|
try:
|
||||||
|
import aco.table as table_pkg
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
for info in pkgutil.iter_modules(table_pkg.__path__):
|
||||||
|
mod = importlib.import_module(f"aco.table.{info.name}")
|
||||||
|
for attr in dir(mod):
|
||||||
|
cls = getattr(mod, attr)
|
||||||
|
if (
|
||||||
|
isinstance(cls, type)
|
||||||
|
and hasattr(cls, "__tablename__")
|
||||||
|
and cls.__tablename__ == table_name
|
||||||
|
):
|
||||||
|
return cls
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{table}")
|
||||||
|
def get_schema(table: str) -> dict:
|
||||||
|
"""Return JSON Schema for a SQLTable model."""
|
||||||
|
cls = _find_table_class(table)
|
||||||
|
if cls is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Table {table!r} not found.")
|
||||||
|
return cls.model_json_schema()
|
||||||
26
src/api/server.py
Normal file
26
src/api/server.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""FastAPI application for the stack platform.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
uv run stack api serve
|
||||||
|
# or directly:
|
||||||
|
uv run uvicorn api.server:app --reload
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from api.routes import auth, bib, health, pipelines, schema
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="stack",
|
||||||
|
description="Healthcare data platform API.",
|
||||||
|
version="0.1.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(auth.router)
|
||||||
|
app.include_router(health.router)
|
||||||
|
app.include_router(pipelines.router)
|
||||||
|
app.include_router(bib.router)
|
||||||
|
app.include_router(schema.router)
|
||||||
@@ -12,6 +12,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
|
||||||
|
from cli.api import app as api_app
|
||||||
from cli.bib import app as bib_app
|
from cli.bib import app as bib_app
|
||||||
from cli.db import app as db_app
|
from cli.db import app as db_app
|
||||||
from cli.docs import app as docs_app
|
from cli.docs import app as docs_app
|
||||||
@@ -35,6 +36,7 @@ app.add_typer(bib_app, name="bib", help="Bibliography operations.")
|
|||||||
app.add_typer(lake_app, name="lake", help="Lakehouse schema and data.")
|
app.add_typer(lake_app, name="lake", help="Lakehouse schema and data.")
|
||||||
app.add_typer(db_app, name="db", help="DuckDB utilities.")
|
app.add_typer(db_app, name="db", help="DuckDB utilities.")
|
||||||
app.add_typer(docs_app, name="docs", help="Documentation generation.")
|
app.add_typer(docs_app, name="docs", help="Documentation generation.")
|
||||||
|
app.add_typer(api_app, name="api", help="API server.")
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
|||||||
33
src/cli/api.py
Normal file
33
src/cli/api.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""stack api — serve the FastAPI application."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import typer
|
||||||
|
|
||||||
|
app = typer.Typer(no_args_is_help=True)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def serve(
|
||||||
|
host: str = typer.Option(None, help="Bind address."),
|
||||||
|
port: int = typer.Option(None, help="Port number."),
|
||||||
|
workers: int = typer.Option(None, help="Number of workers."),
|
||||||
|
reload: bool = typer.Option(False, help="Enable auto-reload."),
|
||||||
|
) -> None:
|
||||||
|
"""Start the stack API server."""
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
from conf import cfg
|
||||||
|
|
||||||
|
_host = host or cfg.api.host
|
||||||
|
_port = port or cfg.api.port
|
||||||
|
_workers = workers or cfg.api.workers
|
||||||
|
|
||||||
|
typer.echo(f"Starting API server on {_host}:{_port} (workers={_workers})")
|
||||||
|
uvicorn.run(
|
||||||
|
"api.server:app",
|
||||||
|
host=_host,
|
||||||
|
port=_port,
|
||||||
|
workers=_workers,
|
||||||
|
reload=reload,
|
||||||
|
)
|
||||||
@@ -122,6 +122,23 @@ def context() -> _Cfg:
|
|||||||
return cfg.context[name]
|
return cfg.context[name]
|
||||||
|
|
||||||
|
|
||||||
|
def secret(dotted_key: str, env_var: str) -> str:
|
||||||
|
"""Read a secret, preferring an env var over the config file.
|
||||||
|
|
||||||
|
Examples::
|
||||||
|
|
||||||
|
secret("api.secret", "STACK_API_SECRET")
|
||||||
|
"""
|
||||||
|
val = os.environ.get(env_var, "")
|
||||||
|
if val:
|
||||||
|
return val
|
||||||
|
parts = dotted_key.split(".")
|
||||||
|
node: Any = cfg._data
|
||||||
|
for p in parts:
|
||||||
|
node = node[p]
|
||||||
|
return str(node)
|
||||||
|
|
||||||
|
|
||||||
def reload() -> None:
|
def reload() -> None:
|
||||||
"""Re-read stack.toml (useful after edits)."""
|
"""Re-read stack.toml (useful after edits)."""
|
||||||
global cfg
|
global cfg
|
||||||
|
|||||||
@@ -73,5 +73,11 @@ host = "trino"
|
|||||||
port = 8080
|
port = 8080
|
||||||
catalog = "iceberg"
|
catalog = "iceberg"
|
||||||
|
|
||||||
|
[api]
|
||||||
|
host = "0.0.0.0"
|
||||||
|
port = 8000
|
||||||
|
secret = "" # override via STACK_API_SECRET env var
|
||||||
|
workers = 1
|
||||||
|
|
||||||
[lint]
|
[lint]
|
||||||
line_length = 88
|
line_length = 88
|
||||||
|
|||||||
104
tests/api/test_server.py
Normal file
104
tests/api/test_server.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
"""Tests for the FastAPI server."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def api_secret(monkeypatch):
|
||||||
|
secret = "test-secret-key-for-jwt"
|
||||||
|
monkeypatch.setenv("STACK_API_SECRET", secret)
|
||||||
|
return secret
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client():
|
||||||
|
from api.server import app
|
||||||
|
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def authed_client(client, api_secret):
|
||||||
|
token = jwt.encode({"sub": "test"}, api_secret, algorithm="HS256")
|
||||||
|
client.headers["Authorization"] = f"Bearer {token}"
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
class TestHealth:
|
||||||
|
def test_health(self, client) -> None:
|
||||||
|
r = client.get("/health")
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert data["status"] == "ok"
|
||||||
|
assert "version" in data
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuth:
|
||||||
|
def test_issue_token(self, client, api_secret) -> None:
|
||||||
|
r = client.post("/auth/token", json={"secret": api_secret})
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert "access_token" in data
|
||||||
|
assert data["token_type"] == "bearer"
|
||||||
|
decoded = jwt.decode(data["access_token"], api_secret, algorithms=["HS256"])
|
||||||
|
assert decoded["sub"] == "stack-api"
|
||||||
|
|
||||||
|
def test_wrong_secret(self, client, api_secret) -> None:
|
||||||
|
r = client.post("/auth/token", json={"secret": "wrong"})
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
def test_no_secret_configured(self, client, monkeypatch) -> None:
|
||||||
|
monkeypatch.delenv("STACK_API_SECRET", raising=False)
|
||||||
|
from conf import cfg
|
||||||
|
|
||||||
|
cfg._data.setdefault("api", {})["secret"] = ""
|
||||||
|
r = client.post("/auth/token", json={"secret": "anything"})
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
class TestPipelines:
|
||||||
|
def test_list_pipelines(self, client) -> None:
|
||||||
|
r = client.get("/pipelines")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert isinstance(r.json(), list)
|
||||||
|
|
||||||
|
def test_get_pipeline(self, client) -> None:
|
||||||
|
r = client.get("/pipelines/readmissions")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["name"] == "readmissions"
|
||||||
|
|
||||||
|
def test_run_requires_auth(self, client) -> None:
|
||||||
|
r = client.post("/pipelines/run/readmissions")
|
||||||
|
assert r.status_code in (401, 403)
|
||||||
|
|
||||||
|
def test_run_with_auth(self, authed_client) -> None:
|
||||||
|
r = authed_client.post("/pipelines/run/readmissions")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["status"] == "queued"
|
||||||
|
|
||||||
|
def test_run_status(self, client) -> None:
|
||||||
|
r = client.get("/pipelines/run/some-job/status")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["job_id"] == "some-job"
|
||||||
|
|
||||||
|
|
||||||
|
class TestBib:
|
||||||
|
def test_list_items(self, client) -> None:
|
||||||
|
r = client.get("/bib/items")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert isinstance(r.json(), list)
|
||||||
|
|
||||||
|
def test_list_tags(self, client) -> None:
|
||||||
|
r = client.get("/bib/tags")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert isinstance(r.json(), list)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSchema:
|
||||||
|
def test_missing_table(self, client) -> None:
|
||||||
|
r = client.get("/schema/nonexistent_table")
|
||||||
|
assert r.status_code == 404
|
||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from conf import ROOT, cfg, context, path, reload
|
from conf import ROOT, cfg, context, path, reload, secret
|
||||||
|
|
||||||
|
|
||||||
class TestCfg:
|
class TestCfg:
|
||||||
@@ -114,6 +114,36 @@ class TestContext:
|
|||||||
context()
|
context()
|
||||||
|
|
||||||
|
|
||||||
|
class TestSecret:
|
||||||
|
def test_env_var_takes_precedence(self, monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("STACK_API_SECRET", "from-env")
|
||||||
|
assert secret("api.secret", "STACK_API_SECRET") == "from-env"
|
||||||
|
|
||||||
|
def test_falls_back_to_config(self, monkeypatch) -> None:
|
||||||
|
monkeypatch.delenv("STACK_API_SECRET", raising=False)
|
||||||
|
# config value is empty string by default
|
||||||
|
assert secret("api.secret", "STACK_API_SECRET") == ""
|
||||||
|
|
||||||
|
def test_reads_non_empty_config(self, monkeypatch) -> None:
|
||||||
|
import conf
|
||||||
|
|
||||||
|
monkeypatch.delenv("_CONF_TEST_SECRET_", raising=False)
|
||||||
|
monkeypatch.setitem(conf.cfg._data["api"], "secret", "from-toml")
|
||||||
|
result = secret("api.secret", "_CONF_TEST_SECRET_")
|
||||||
|
assert result == "from-toml"
|
||||||
|
|
||||||
|
|
||||||
|
class TestApiConfig:
|
||||||
|
def test_api_host(self) -> None:
|
||||||
|
assert cfg.api.host == "0.0.0.0"
|
||||||
|
|
||||||
|
def test_api_port(self) -> None:
|
||||||
|
assert cfg.api.port == 8000
|
||||||
|
|
||||||
|
def test_api_workers(self) -> None:
|
||||||
|
assert cfg.api.workers == 1
|
||||||
|
|
||||||
|
|
||||||
class TestFindRootError:
|
class TestFindRootError:
|
||||||
def test_raises_when_no_stack_toml(self, tmp_path: Path) -> None:
|
def test_raises_when_no_stack_toml(self, tmp_path: Path) -> None:
|
||||||
import pytest
|
import pytest
|
||||||
|
|||||||
48
uv.lock
generated
48
uv.lock
generated
@@ -586,6 +586,22 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" },
|
{ url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastapi"
|
||||||
|
version = "0.135.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "annotated-doc" },
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "starlette" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
{ name = "typing-inspection" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962, upload-time = "2026-03-01T18:18:29.369Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastexcel"
|
name = "fastexcel"
|
||||||
version = "0.19.0"
|
version = "0.19.0"
|
||||||
@@ -1668,13 +1684,16 @@ source = { virtual = "." }
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "databricks-cli" },
|
{ name = "databricks-cli" },
|
||||||
{ name = "databricks-sdk" },
|
{ name = "databricks-sdk" },
|
||||||
|
{ name = "fastapi" },
|
||||||
{ name = "fastexcel" },
|
{ name = "fastexcel" },
|
||||||
{ name = "fsspec" },
|
{ name = "fsspec" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "narwhals" },
|
{ name = "narwhals" },
|
||||||
{ name = "pyarrow" },
|
{ name = "pyarrow" },
|
||||||
|
{ name = "pyjwt" },
|
||||||
{ name = "sqlglot" },
|
{ name = "sqlglot" },
|
||||||
{ name = "typer" },
|
{ name = "typer" },
|
||||||
|
{ name = "uvicorn" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
@@ -1694,13 +1713,16 @@ dev = [
|
|||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "databricks-cli", specifier = ">=0.18.0" },
|
{ name = "databricks-cli", specifier = ">=0.18.0" },
|
||||||
{ name = "databricks-sdk", specifier = ">=0.85.0" },
|
{ name = "databricks-sdk", specifier = ">=0.85.0" },
|
||||||
|
{ name = "fastapi", specifier = ">=0.135.1" },
|
||||||
{ name = "fastexcel", specifier = ">=0.19.0" },
|
{ name = "fastexcel", specifier = ">=0.19.0" },
|
||||||
{ name = "fsspec", specifier = ">=2024.1.0" },
|
{ name = "fsspec", specifier = ">=2024.1.0" },
|
||||||
{ name = "httpx", specifier = ">=0.28.1" },
|
{ name = "httpx", specifier = ">=0.28.1" },
|
||||||
{ name = "narwhals", specifier = ">=2.17.0" },
|
{ name = "narwhals", specifier = ">=2.17.0" },
|
||||||
{ name = "pyarrow", specifier = ">=23.0.0" },
|
{ name = "pyarrow", specifier = ">=23.0.0" },
|
||||||
|
{ name = "pyjwt", specifier = ">=2.11.0" },
|
||||||
{ name = "sqlglot", specifier = ">=26.0.0" },
|
{ name = "sqlglot", specifier = ">=26.0.0" },
|
||||||
{ name = "typer", specifier = ">=0.24.1" },
|
{ name = "typer", specifier = ">=0.24.1" },
|
||||||
|
{ name = "uvicorn", specifier = ">=0.41.0" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
@@ -1716,6 +1738,19 @@ dev = [
|
|||||||
{ name = "ruff", specifier = ">=0.11.0" },
|
{ name = "ruff", specifier = ">=0.11.0" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "starlette"
|
||||||
|
version = "0.52.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tabulate"
|
name = "tabulate"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -1788,6 +1823,19 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uvicorn"
|
||||||
|
version = "0.41.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zipp"
|
name = "zipp"
|
||||||
version = "3.23.0"
|
version = "3.23.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user