wire stack.toml into all modules, add context-aware backend switching refs #1

Replace hardcoded path defaults in bib, bcda, rex, and pfs modules
with conf.path() lookups from stack.toml.  Add [context] section with
local/lake/databricks/trino profiles selectable via STACK_CONTEXT env
var or context.active config key.
This commit is contained in:
kert
2026-03-12 14:07:05 -04:00
parent 7dffb76801
commit 560df768d0
11 changed files with 143 additions and 20 deletions

View File

@@ -129,9 +129,13 @@ class Store:
def __init__(
self,
path: str | Path = "data/bcda",
path: str | Path | None = None,
storage_options: dict[str, Any] | None = None,
) -> None:
if path is None:
from conf import path as _path
path = str(_path("storage.bcda"))
self._root = str(path).rstrip("/")
self._storage_options = storage_options or {}
self._filesystem: fsspec.AbstractFileSystem | None = None

View File

@@ -38,18 +38,22 @@ COLLECTIONS = {
}
def connect(database: str = "data/bib.sqlite") -> Store:
def connect(database: str | None = None) -> Store:
"""Return a Store backed by the given SQLite database.
Parameters
----------
database : str
Path to the SQLite file. Defaults to ``data/bib.sqlite``.
Use ``":memory:"`` for in-memory operation.
database : str, optional
Path to the SQLite file. Defaults to ``db.bib`` from
``stack.toml``. Use ``":memory:"`` for in-memory operation.
Returns
-------
Store
A ready-to-use bibliography store.
"""
if database is None:
from conf import path
database = str(path("db.bib"))
return Store(database)

View File

@@ -39,7 +39,12 @@ if TYPE_CHECKING:
log = logging.getLogger(__name__)
ZOTERO_STORAGE = Path("zotero/data/storage")
def _zotero_storage() -> Path:
from conf import path
return path("storage.zotero")
# ── URL classification ────────────────────────────────────────────
@@ -78,7 +83,7 @@ def _get_attachments(store: Store, item_key: str) -> list[dict]:
def _read_attachment(att_key: str, filename: str) -> str:
"""Read an attachment file from Zotero storage as text."""
path = ZOTERO_STORAGE / att_key / filename
path = _zotero_storage() / att_key / filename
if not path.exists():
return ""
try:

View File

@@ -49,10 +49,14 @@ class Store:
def __init__(
self,
database: str | Path = "data/bib.sqlite",
database: str | Path | None = None,
*,
storage_dir: str | Path = "",
) -> None:
if database is None:
from conf import path
database = str(path("db.bib"))
self._db_path = str(database)
self._connection: sqlite3.Connection | None = None

View File

@@ -219,9 +219,13 @@ def _item_to_zotero_fields(item: Item) -> dict[str, str]:
def push_to_zotero(
items: list[Item],
*,
zotero_db: str = "zotero/data/zotero.sqlite",
zotero_db: str | None = None,
collection_key: str = "",
) -> dict[str, int]:
if zotero_db is None:
from conf import path
zotero_db = str(path("db.zotero"))
"""Push bib items into Zotero's SQLite database.
Parameters

View File

@@ -16,10 +16,19 @@ Paths can be resolved to absolute ``Path`` objects::
path("db.aco") # Path("/home/.../notebooks/aco.duckdb")
path("storage.bib") # Path("/home/.../data/bib/storage")
Context-aware switching::
from conf import context
ctx = context() # reads [context.active] or STACK_CONTEXT env
ctx.db_backend # "duckdb" | "iceberg" | "trino" | "databricks"
ctx.storage_backend # "local" | "s3" | "dbfs"
"""
from __future__ import annotations
import os
import tomllib
from pathlib import Path
from typing import Any
@@ -96,6 +105,23 @@ def path(dotted_key: str) -> Path:
return result
def context() -> _Cfg:
"""Return the active context profile.
Resolution order:
1. ``STACK_CONTEXT`` environment variable
2. ``context.active`` in stack.toml
Returns the ``[context.<name>]`` sub-table as a ``_Cfg`` object
with keys like ``db_backend``, ``storage_backend``, etc.
Raises ``KeyError`` if the named context doesn't exist in
``stack.toml``.
"""
name = os.environ.get("STACK_CONTEXT") or cfg.context.active
return cfg.context[name]
def reload() -> None:
"""Re-read stack.toml (useful after edits)."""
global cfg

View File

@@ -1656,8 +1656,8 @@ def _insert_into(
def load_all(
con: duckdb.DuckDBPyConnection,
*,
zotero_db: str | Path = "zotero/data/zotero.sqlite",
zotero_storage: str | Path = "zotero/data/storage",
zotero_db: str | Path | None = None,
zotero_storage: str | Path | None = None,
years: tuple[int, int] | None = None,
) -> dict[str, dict[str, Any]]:
"""Load all PFS data from Zotero into DuckDB tables.
@@ -1666,10 +1666,12 @@ def load_all(
----------
con : duckdb.DuckDBPyConnection
DuckDB connection to load into.
zotero_db : str | Path
Path to Zotero SQLite database.
zotero_storage : str | Path
Path to Zotero storage directory.
zotero_db : str | Path, optional
Path to Zotero SQLite database. Defaults to ``db.zotero``
from ``stack.toml``.
zotero_storage : str | Path, optional
Path to Zotero storage directory. Defaults to
``storage.zotero`` from ``stack.toml``.
years : tuple[int, int] | None
Optional (start, end) year range filter (inclusive).
@@ -1678,6 +1680,13 @@ def load_all(
dict[str, dict]
Per-table stats: ``{"pfs.rvu": {"rows": N, "files": N}, ...}``
"""
if zotero_db is None or zotero_storage is None:
from conf import path
if zotero_db is None:
zotero_db = path("db.zotero")
if zotero_storage is None:
zotero_storage = path("storage.zotero")
log.info("Discovering PFS files from Zotero...")
all_files = _zotero_pfs_files(zotero_db, zotero_storage)

View File

@@ -129,9 +129,13 @@ class Store:
def __init__(
self,
path: str | Path = "data/rex",
path: str | Path | None = None,
storage_options: dict[str, Any] | None = None,
) -> None:
if path is None:
from conf import path as _path
path = str(_path("storage.rex"))
self._root = str(path).rstrip("/")
self._storage_options = storage_options or {}
self._filesystem: fsspec.AbstractFileSystem | None = None

View File

@@ -2,6 +2,36 @@
#
# Paths are relative to the repository root unless absolute.
# Secrets live in .env (never here).
#
# Context switching
# -----------------
# [context.active] selects the environment profile. Override at
# runtime with STACK_CONTEXT=lake (env var beats this file).
#
# Each context sets db_backend and storage_backend. Modules call
# conf.context() to decide *which* engine or filesystem to use;
# the [db], [storage], and [lake] sections supply the concrete
# connection details for the selected backend.
[context]
active = "local"
[context.local]
db_backend = "duckdb"
storage_backend = "local"
[context.lake]
db_backend = "iceberg"
storage_backend = "s3"
catalog = "nessie"
[context.databricks]
db_backend = "databricks"
storage_backend = "dbfs"
[context.trino]
db_backend = "trino"
storage_backend = "s3"
[db]
aco = "notebooks/aco.duckdb"

View File

@@ -273,7 +273,7 @@ class TestGetAttachments:
class TestReadAttachment:
def test_reads_file(self, tmp_path: Path) -> None:
with patch("bib.spider.ZOTERO_STORAGE", tmp_path):
with patch("bib.spider._zotero_storage", return_value=tmp_path):
att_dir = tmp_path / "ATT12345"
att_dir.mkdir()
(att_dir / "doc.txt").write_text("hello world")
@@ -281,13 +281,13 @@ class TestReadAttachment:
assert content == "hello world"
def test_missing_file(self, tmp_path: Path) -> None:
with patch("bib.spider.ZOTERO_STORAGE", tmp_path):
with patch("bib.spider._zotero_storage", return_value=tmp_path):
content = _read_attachment("NOEXIST1", "doc.txt")
assert content == ""
def test_read_error(self, tmp_path: Path) -> None:
with (
patch("bib.spider.ZOTERO_STORAGE", tmp_path),
patch("bib.spider._zotero_storage", return_value=tmp_path),
patch("pathlib.Path.read_text", side_effect=OSError("read error")),
):
att_dir = tmp_path / "ATT12345"

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from pathlib import Path
from conf import ROOT, cfg, path, reload
from conf import ROOT, cfg, context, path, reload
class TestCfg:
@@ -81,6 +81,39 @@ class TestReload:
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 TestFindRootError:
def test_raises_when_no_stack_toml(self, tmp_path: Path) -> None:
import pytest