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.
168 lines
5.0 KiB
Python
168 lines
5.0 KiB
Python
"""Tests for conf — centralised configuration loader."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
from conf import ROOT, cfg, context, path, reload, secret
|
|
|
|
|
|
class TestCfg:
|
|
def test_root_is_repo_root(self) -> None:
|
|
assert (ROOT / "stack.toml").exists()
|
|
assert (ROOT / "pyproject.toml").exists()
|
|
|
|
def test_db_section_exists(self) -> None:
|
|
assert "db" in cfg
|
|
|
|
def test_db_aco_value(self) -> None:
|
|
assert cfg.db.aco == "notebooks/aco.duckdb"
|
|
|
|
def test_db_bib_value(self) -> None:
|
|
assert cfg.db.bib == "data/bib.sqlite"
|
|
|
|
def test_db_zotero_value(self) -> None:
|
|
assert cfg.db.zotero == "zotero/data/zotero.sqlite"
|
|
|
|
def test_storage_bcda(self) -> None:
|
|
assert cfg.storage.bcda == "data/bcda"
|
|
|
|
def test_bcda_timeout_is_float(self) -> None:
|
|
assert isinstance(cfg.bcda.timeout, float)
|
|
|
|
def test_bcda_max_retries_is_int(self) -> None:
|
|
assert isinstance(cfg.bcda.max_retries, int)
|
|
|
|
def test_lake_trino_port_is_int(self) -> None:
|
|
assert isinstance(cfg.lake.trino.port, int)
|
|
|
|
def test_dict_access(self) -> None:
|
|
val = cfg["db"]["aco"]
|
|
assert val == "notebooks/aco.duckdb"
|
|
|
|
def test_to_dict(self) -> None:
|
|
d = cfg.db.to_dict()
|
|
assert isinstance(d, dict)
|
|
assert "aco" in d
|
|
|
|
def test_contains(self) -> None:
|
|
assert "db" in cfg
|
|
assert "nonexistent_key" not in cfg
|
|
|
|
def test_repr(self) -> None:
|
|
r = repr(cfg.db)
|
|
assert "aco" in r
|
|
|
|
def test_missing_attr_raises(self) -> None:
|
|
import pytest
|
|
|
|
with pytest.raises(AttributeError):
|
|
cfg.no_such_section
|
|
|
|
|
|
class TestPath:
|
|
def test_path_returns_absolute(self) -> None:
|
|
p = path("db.aco")
|
|
assert isinstance(p, Path)
|
|
assert p.is_absolute()
|
|
|
|
def test_path_resolves_relative_to_root(self) -> None:
|
|
p = path("db.aco")
|
|
assert p == ROOT / "notebooks/aco.duckdb"
|
|
|
|
def test_path_nested_key(self) -> None:
|
|
p = path("db.bib")
|
|
assert p == ROOT / "data/bib.sqlite"
|
|
|
|
|
|
class TestReload:
|
|
def test_reload_does_not_raise(self) -> None:
|
|
reload()
|
|
assert cfg.db.aco == "notebooks/aco.duckdb"
|
|
|
|
|
|
class TestContext:
|
|
def test_default_context_is_local(self) -> None:
|
|
ctx = context()
|
|
assert ctx.db_backend == "duckdb"
|
|
assert ctx.storage_backend == "local"
|
|
|
|
def test_env_var_overrides_active(self, monkeypatch) -> None:
|
|
monkeypatch.setenv("STACK_CONTEXT", "lake")
|
|
reload()
|
|
ctx = context()
|
|
assert ctx.db_backend == "iceberg"
|
|
assert ctx.storage_backend == "s3"
|
|
|
|
def test_databricks_context(self, monkeypatch) -> None:
|
|
monkeypatch.setenv("STACK_CONTEXT", "databricks")
|
|
ctx = context()
|
|
assert ctx.db_backend == "databricks"
|
|
assert ctx.storage_backend == "dbfs"
|
|
|
|
def test_trino_context(self, monkeypatch) -> None:
|
|
monkeypatch.setenv("STACK_CONTEXT", "trino")
|
|
ctx = context()
|
|
assert ctx.db_backend == "trino"
|
|
assert ctx.storage_backend == "s3"
|
|
|
|
def test_unknown_context_raises(self, monkeypatch) -> None:
|
|
import pytest
|
|
|
|
monkeypatch.setenv("STACK_CONTEXT", "nonexistent")
|
|
with pytest.raises(KeyError):
|
|
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:
|
|
def test_raises_when_no_stack_toml(self, tmp_path: Path) -> None:
|
|
import pytest
|
|
|
|
# Monkeypatch __file__ won't work easily, but we can test
|
|
# the function directly with a path that has no stack.toml
|
|
import conf
|
|
from conf import _find_root
|
|
|
|
original = conf.__file__
|
|
try:
|
|
# Point to a deep tmp path with no stack.toml
|
|
deep = tmp_path / "a" / "b" / "c" / "d" / "e"
|
|
deep.mkdir(parents=True)
|
|
fake_init = deep / "__init__.py"
|
|
fake_init.write_text("")
|
|
conf.__file__ = str(fake_init)
|
|
with pytest.raises(FileNotFoundError, match="stack.toml"):
|
|
_find_root()
|
|
finally:
|
|
conf.__file__ = original
|