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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
30
stack.toml
30
stack.toml
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user