forked from ChelseaKR/queer-the-stacks
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathconfig.py
More file actions
204 lines (172 loc) · 7.48 KB
/
Copy pathconfig.py
File metadata and controls
204 lines (172 loc) · 7.48 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
"""Runtime configuration — where the real Calibre/KOReader/Kobo libraries live.
Resolution order (later overrides earlier):
1. built-in defaults (demo off, ``data/`` for app state + snapshots),
2. a TOML config file (``stacks.toml`` by default, or ``$STACKS_CONFIG``),
3. ``STACKS_*`` environment variables.
Secrets (the kosync key) come from the environment only, never a committed file.
Nothing here opens a database or the network — it only resolves paths and flags,
so it is safe to import everywhere and trivial to test.
"""
from __future__ import annotations
import os
import tomllib
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
DEFAULT_CONFIG_FILE = "stacks.toml"
DEFAULT_DATA_DIR = Path("data")
#: Personalized diversity-lens grouping, auto-loaded when present (see
#: app/diversity.py::load_lens_config). Overridable via [lenses].path in
#: stacks.toml or $STACKS_LENS_CONFIG; ships as a committed template with the
#: current defaults so editing it "just works" with zero code changes.
DEFAULT_LENS_CONFIG = Path("data/lenses.toml")
# Every ``STACKS_*`` environment variable this app actually reads. Exported so
# ``ingest.refresh.doctor`` can flag typos (e.g. ``STACKS_CALIBER_DB``) without
# hardcoding a second copy of this list that can drift out of sync.
KNOWN_STACKS_ENV = frozenset(
{
"STACKS_CONFIG",
"STACKS_DATA_DIR",
"STACKS_DEMO",
"STACKS_CALIBRE_DB",
"STACKS_KOREADER_DB",
"STACKS_KOBO_DB",
"STACKS_KOSYNC_HOST",
"STACKS_KOSYNC_USER",
"STACKS_KOSYNC_KEY",
"STACKS_APERTURE",
"STACKS_EMBEDDINGS",
"STACKS_DNF_SIGNALS",
"STACKS_GOAL_BOOKS",
"STACKS_GOAL_PAGES",
"STACKS_GOAL_HOURS",
"STACKS_GOAL_STREAK",
"STACKS_AUTH_TOKEN",
"STACKS_HIDE_SENSITIVE",
"STACKS_CALIBRE_WEB_URL",
"STACKS_LENS_CONFIG",
}
)
@dataclass(frozen=True)
class Config:
"""Resolved runtime configuration. All paths are absolute-or-relative as given."""
calibre_db: Optional[Path]
koreader_db: Optional[Path]
data_dir: Path
kosync_host: Optional[str]
kosync_user: Optional[str]
kosync_key: Optional[str] # md5 key, from the environment only
demo: bool
kobo_db: Optional[Path] = None # Kobo's native KoboReader.sqlite (read-only source)
aperture_strength: float = 0.0 # boost-only discovery-widening lens (0 = off)
embeddings_enabled: bool = False # optional, strictly-local semantic signal
dnf_signals: bool = False # opt-in soft down-weighting of stalled themes
goal_books: int = 0 # yearly book goal (0 = unset)
goal_pages: int = 0 # yearly page goal (0 = unset)
goal_hours: int = 0 # yearly reading-time goal in hours (0 = unset)
goal_streak_days: int = 0 # streak goal in days (0 = unset)
lens_config: Optional[Path] = None # diversity-lens TOML override, if any
hide_sensitive_descriptors: bool = False # privacy: aggregate identity-adjacent tags
@property
def store_path(self) -> Path:
"""Where the persisted derived-state SQLite store lives."""
return self.data_dir / "app-state.sqlite"
@property
def snapshot_dir(self) -> Path:
"""Where read-only snapshots of the source libraries are written."""
return self.data_dir / "snapshots"
@property
def kosync_configured(self) -> bool:
return bool(self.kosync_host and self.kosync_user and self.kosync_key)
@property
def has_real_sources(self) -> bool:
return (
self.calibre_db is not None or self.koreader_db is not None or self.kobo_db is not None
)
def view_cache_fields(self) -> tuple[object, ...]:
"""The subset of fields that affect a built ``DashboardView``.
Used by the server to key its in-memory view cache: source/storage
paths and secrets never change what gets rendered, only these knobs do
(see ``app.view.build_view``'s keyword arguments).
"""
return (
self.demo,
self.aperture_strength,
self.embeddings_enabled,
self.dnf_signals,
self.goal_books,
self.goal_pages,
self.goal_hours,
self.goal_streak_days,
)
def _read_toml(path: Path) -> dict[str, object]:
if not path.is_file():
return {}
with path.open("rb") as fh:
data = tomllib.load(fh)
return data if isinstance(data, dict) else {}
def _section(data: Mapping[str, object], name: str) -> Mapping[str, object]:
value = data.get(name)
return value if isinstance(value, Mapping) else {}
def _opt_path(value: Optional[str]) -> Optional[Path]:
return Path(value) if value else None
def load_config(
env: Optional[Mapping[str, str]] = None,
config_path: Optional[Path] = None,
) -> Config:
"""Resolve configuration from a TOML file overlaid with ``STACKS_*`` env vars."""
resolved_env: Mapping[str, str] = os.environ if env is None else env
path = config_path or Path(resolved_env.get("STACKS_CONFIG", DEFAULT_CONFIG_FILE))
toml = _read_toml(path)
calibre = _section(toml, "calibre")
koreader = _section(toml, "koreader")
kobo = _section(toml, "kobo")
kosync = _section(toml, "kosync")
storage = _section(toml, "storage")
def pick(env_key: str, section: Mapping[str, object], key: str) -> Optional[str]:
if env_key in resolved_env and resolved_env[env_key]:
return resolved_env[env_key]
raw = section.get(key)
return str(raw) if isinstance(raw, (str, int)) else None
data_dir = _opt_path(pick("STACKS_DATA_DIR", storage, "data_dir")) or DEFAULT_DATA_DIR
rec = _section(toml, "recommender")
aperture_raw = pick("STACKS_APERTURE", rec, "aperture_strength")
try:
aperture = max(0.0, min(1.0, float(aperture_raw))) if aperture_raw else 0.0
except ValueError:
aperture = 0.0
goals = _section(toml, "goals")
def pick_int(env_key: str, key: str) -> int:
raw = pick(env_key, goals, key)
try:
return max(0, int(raw)) if raw else 0
except ValueError:
return 0
lenses = _section(toml, "lenses")
lens_config_raw = pick("STACKS_LENS_CONFIG", lenses, "path")
if lens_config_raw:
lens_config = Path(lens_config_raw)
elif DEFAULT_LENS_CONFIG.is_file():
lens_config = DEFAULT_LENS_CONFIG
else:
lens_config = None
return Config(
calibre_db=_opt_path(pick("STACKS_CALIBRE_DB", calibre, "path")),
koreader_db=_opt_path(pick("STACKS_KOREADER_DB", koreader, "path")),
kobo_db=_opt_path(pick("STACKS_KOBO_DB", kobo, "path")),
data_dir=data_dir,
kosync_host=pick("STACKS_KOSYNC_HOST", kosync, "host"),
kosync_user=pick("STACKS_KOSYNC_USER", kosync, "user"),
kosync_key=resolved_env.get("STACKS_KOSYNC_KEY") or None, # secret: env only
demo=resolved_env.get("STACKS_DEMO") == "1",
aperture_strength=aperture,
embeddings_enabled=resolved_env.get("STACKS_EMBEDDINGS") == "1",
dnf_signals=resolved_env.get("STACKS_DNF_SIGNALS") == "1",
goal_books=pick_int("STACKS_GOAL_BOOKS", "books"),
goal_pages=pick_int("STACKS_GOAL_PAGES", "pages"),
goal_hours=pick_int("STACKS_GOAL_HOURS", "hours"),
goal_streak_days=pick_int("STACKS_GOAL_STREAK", "streak_days"),
lens_config=lens_config,
hide_sensitive_descriptors=resolved_env.get("STACKS_HIDE_SENSITIVE") == "1",
)