Skip to content

Commit 14a8a16

Browse files
jschlomanclaude
andauthored
feat: LocalSettings module for gitignored local path persistence (#38)
* docs: public-share readiness, dark-mode flythrough, CI improvements - Fix record_flythrough CLI: --marker_zoom (was --marker_width in docs), add required csv positional arg to examples, expand all argparse help strings, replace args bullet list with full argument table in README - Update flythrough map style to dark basemap with teal→amber column palette matching the application dark-mode theme; import palette constants from components/theme.py for consistency - Add CI badge, Python 3.9+ badge, and GPL badge to README header - Update CI branch triggers to cover all conventional commit prefixes (feat/*, fix/*, docs/*, refactor/*, chore/*, ci/*, etc.), add pip caching, split into named steps, use pip install -e "[dev]" - Add .pre-commit-config.yaml with ruff and mypy hooks - Add pyproject.toml [project.dependencies], [project.optional-dependencies.dev], [tool.pytest.ini_options], and [tool.coverage] sections; pytest now runs with correct flags from config (no manual flag passing needed) - Correct coverage threshold to 70% (source-only, tests/ excluded from measurement; previously 80% was met by inflating numbers with test files) - Add CHANGELOG.md (Keep a Changelog format) - Add data/.gitkeep so data/ directory exists after a fresh clone - Replace assets/example screenshot.png with map and stats variants; remove dashboard_mockup.png - Update flythrough.gif to last 10s of tour at 480px/8fps (3.47 MB, within GitHub's 5 MB inline rendering limit) - Correct Python minimum version from 3.8 to 3.9 in README and pyproject.toml - Update CLAUDE.md local gate to use bare pytest (flags now in pyproject.toml) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: LocalSettings module for gitignored local path persistence Replaces the inline data/config.json logic in components/sidebar.py with a proper core.local_settings.LocalSettings class. Paths are now stored in local_settings.json at the project root under a nested plugins structure, making the file easy to pre-populate manually. Adds local_settings.json.example as a committed template. Also removes a Django-boilerplate gitignore entry that was incorrectly shadowing core/local_settings.py. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: ignore editor backup files (*~ and .*.un~) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b4f007e commit 14a8a16

7 files changed

Lines changed: 341 additions & 110 deletions

File tree

.gitignore

129 Bytes
Binary file not shown.

components/sidebar.py

Lines changed: 29 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@
44
each declared config field, then loads the active DataFrame into
55
``st.session_state["df"]`` so every page can access it without reloading.
66
7-
Path selections are persisted to ``data/config.json`` so they survive
8-
application restarts and do not need to be re-entered each session.
7+
Path selections are persisted to ``local_settings.json`` (gitignored) via
8+
:class:`~core.local_settings.LocalSettings` so they survive application
9+
restarts and do not need to be re-entered each session.
910
"""
1011

1112
from __future__ import annotations
1213

13-
import json
1414
import os
15-
from typing import Any
15+
from typing import Any, Callable
1616

1717
import pandas as pd
1818
import streamlit as st
@@ -26,6 +26,7 @@
2626
load_swarm_data,
2727
save_to_cache,
2828
)
29+
from core.local_settings import LocalSettings
2930
from plugins.sources import REGISTRY, load_builtin_plugins
3031

3132
# Detect tkinter availability at import time so the browse button is only
@@ -37,66 +38,33 @@
3738
except Exception:
3839
_TKINTER_AVAILABLE = False
3940

40-
_CONFIG_PATH = os.path.join("data", "config.json")
41-
# Session state key used to track whether we have already loaded the config
42-
# file into session state in this session.
4341
_CONFIG_LOADED_KEY = "_autobio_config_loaded"
4442

45-
46-
def _read_config() -> dict[str, str]:
47-
"""Read persisted path config from disk.
48-
49-
Returns:
50-
Dict of session_key → path strings, or an empty dict if the file does
51-
not exist or cannot be parsed.
52-
"""
53-
if not os.path.exists(_CONFIG_PATH):
54-
return {}
55-
try:
56-
with open(_CONFIG_PATH) as f:
57-
result: dict[str, str] = json.load(f)
58-
return result
59-
except Exception:
60-
return {}
61-
62-
63-
def _write_config_entry(session_key: str, value: str) -> None:
64-
"""Persist a single path entry to the config file on disk.
65-
66-
Reads the current file (if any), updates the entry, then writes it back
67-
atomically to avoid partial-write corruption.
68-
69-
Args:
70-
session_key: The session state key (used as the config file key).
71-
value: The path value to persist.
72-
"""
73-
config = _read_config()
74-
config[session_key] = value
75-
os.makedirs(os.path.dirname(_CONFIG_PATH), exist_ok=True)
76-
tmp_path = _CONFIG_PATH + ".tmp"
77-
with open(tmp_path, "w") as f:
78-
json.dump(config, f, indent=2)
79-
os.replace(tmp_path, _CONFIG_PATH)
43+
# Module-level singleton — reads local_settings.json once on first import.
44+
_settings = LocalSettings()
8045

8146

8247
def _load_config_into_session_state() -> None:
83-
"""Hydrate session state from the persisted config file (once per session).
48+
"""Hydrate session state from local_settings.json (once per browser session).
8449
8550
Only runs on the first script execution in a new browser session. Existing
8651
session state keys are not overwritten so that in-session changes take
8752
precedence over the saved config.
8853
"""
8954
if st.session_state.get(_CONFIG_LOADED_KEY):
9055
return
91-
for key, value in _read_config().items():
92-
if key not in st.session_state:
93-
st.session_state[key] = value
56+
for plugin_id, plugin_cfg in _settings.get_all_plugin_configs().items():
57+
for field_key, value in plugin_cfg.items():
58+
session_key = f"{plugin_id}_{field_key}"
59+
if session_key not in st.session_state:
60+
st.session_state[session_key] = value
9461
st.session_state[_CONFIG_LOADED_KEY] = True
9562

9663

9764
def _path_input(
9865
label: str,
9966
session_key: str,
67+
on_persist: Callable[[str], None],
10068
default: str = "",
10169
file_types: list[tuple[str, str]] | None = None,
10270
is_dir: bool = False,
@@ -113,12 +81,14 @@ def _path_input(
11381
and triggers a rerun. At the top of the *next* run this value is transferred
11482
to the widget key before the widget is instantiated, which is always allowed.
11583
116-
Any path selected via the dialog or typed into the text input is persisted
117-
to ``data/config.json`` so it survives application restarts.
84+
Any path selected via the dialog or typed into the text input is passed to
85+
``on_persist`` so it can be saved to ``local_settings.json``.
11886
11987
Args:
12088
label: Display label for the text input.
12189
session_key: Session state key used to persist the path value.
90+
on_persist: Callback invoked with the new path string whenever the value
91+
changes; responsible for writing to ``local_settings.json``.
12292
default: Default path value when neither session state nor the saved
12393
config has an entry for this key.
12494
file_types: List of (description, glob pattern) tuples for the file
@@ -136,13 +106,12 @@ def _path_input(
136106
if pending_key in st.session_state:
137107
value = str(st.session_state.pop(pending_key))
138108
st.session_state[session_key] = value
139-
_write_config_entry(session_key, value)
109+
on_persist(value)
140110
elif session_key not in st.session_state:
141111
st.session_state[session_key] = default
142112

143113
def _on_change() -> None:
144-
"""Persist the updated text-input value to the config file."""
145-
_write_config_entry(session_key, str(st.session_state.get(session_key, "")))
114+
on_persist(str(st.session_state.get(session_key, "")))
146115

147116
if _TKINTER_AVAILABLE:
148117
# Use st.columns / st.text_input (not st.sidebar.*) so widgets render
@@ -212,9 +181,18 @@ def _render_plugin_config(plugin_id: str, fields: list[dict[str, Any]]) -> dict[
212181
env_key = f"AUTOBIO_{plugin_id.upper()}_{field['key'].upper()}"
213182
default = os.getenv(env_key, "")
214183
is_dir = field.get("type") == "dir_path"
184+
field_key = field["key"]
185+
186+
def _make_persist(fk: str = field_key) -> Callable[[str], None]:
187+
def _persist(path: str) -> None:
188+
_settings.set_plugin_value(plugin_id, fk, path)
189+
190+
return _persist
191+
215192
value = _path_input(
216193
label=field["label"],
217194
session_key=f"{plugin_id}_{field['key']}",
195+
on_persist=_make_persist(),
218196
default=default,
219197
file_types=field.get("file_types"),
220198
is_dir=is_dir,

core/local_settings.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"""Persistent local settings manager for autobiographer.
2+
3+
Settings are stored in ``local_settings.json`` at the project root. That file
4+
is gitignored so personal paths and preferences never enter version control.
5+
Copy ``local_settings.json.example`` to ``local_settings.json`` to pre-populate
6+
plugin paths without going through the UI.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import json
12+
import os
13+
from typing import Any
14+
15+
_DEFAULT_PATH = "local_settings.json"
16+
17+
18+
class LocalSettings:
19+
"""Read/write persistent local settings to a gitignored JSON file.
20+
21+
Plugin configs are stored under a ``"plugins"`` key, grouped by plugin ID::
22+
23+
{
24+
"plugins": {
25+
"lastfm": {"data_path": "/path/to/tracks.csv"},
26+
"swarm": {"swarm_dir": "/path/to/export/"}
27+
}
28+
}
29+
30+
Args:
31+
path: Path to the settings file. Defaults to ``local_settings.json``
32+
relative to the current working directory.
33+
"""
34+
35+
def __init__(self, path: str = _DEFAULT_PATH) -> None:
36+
self._path = path
37+
self._data: dict[str, Any] = self._load()
38+
39+
def _load(self) -> dict[str, Any]:
40+
"""Read the settings file from disk.
41+
42+
Returns:
43+
Parsed settings dict, or empty dict if file absent or unreadable.
44+
"""
45+
if not os.path.exists(self._path):
46+
return {}
47+
try:
48+
with open(self._path) as f:
49+
data = json.load(f)
50+
return data if isinstance(data, dict) else {}
51+
except Exception:
52+
return {}
53+
54+
def _save(self) -> None:
55+
"""Write current settings to disk atomically.
56+
57+
Uses a tmp-then-rename pattern to prevent partial-write corruption.
58+
Creates parent directories as needed.
59+
"""
60+
parent = os.path.dirname(os.path.abspath(self._path))
61+
os.makedirs(parent, exist_ok=True)
62+
tmp = self._path + ".tmp"
63+
with open(tmp, "w") as f:
64+
json.dump(self._data, f, indent=2)
65+
os.replace(tmp, self._path)
66+
67+
# ── Plugin config helpers ─────────────────────────────────────────────────
68+
69+
def get_all_plugin_configs(self) -> dict[str, dict[str, Any]]:
70+
"""Return stored configs for all plugins.
71+
72+
Returns:
73+
Dict of plugin_id → {field_key: value}. Empty if none saved.
74+
"""
75+
plugins = self._data.get("plugins", {})
76+
if not isinstance(plugins, dict):
77+
return {}
78+
return {k: dict(v) for k, v in plugins.items() if isinstance(v, dict)}
79+
80+
def get_plugin_config(self, plugin_id: str) -> dict[str, Any]:
81+
"""Return all stored settings for a single plugin.
82+
83+
Args:
84+
plugin_id: Plugin identifier (e.g. ``"lastfm"``).
85+
86+
Returns:
87+
Dict of field_key → value. Empty dict if no settings saved yet.
88+
"""
89+
return self.get_all_plugin_configs().get(plugin_id, {})
90+
91+
def set_plugin_value(self, plugin_id: str, field_key: str, value: str) -> None:
92+
"""Persist a single plugin config value and write to disk.
93+
94+
Args:
95+
plugin_id: Plugin identifier (e.g. ``"lastfm"``).
96+
field_key: Config field key (e.g. ``"data_path"``).
97+
value: String value to store.
98+
"""
99+
plugins: dict[str, Any] = self._data.setdefault("plugins", {})
100+
plugin_cfg: dict[str, str] = plugins.setdefault(plugin_id, {})
101+
plugin_cfg[field_key] = value
102+
self._save()
103+
104+
# ── General key/value helpers ─────────────────────────────────────────────
105+
106+
def get(self, key: str, default: Any = None) -> Any:
107+
"""Return a top-level setting value.
108+
109+
Args:
110+
key: Setting key.
111+
default: Value to return if key is absent.
112+
113+
Returns:
114+
The stored value, or ``default`` if the key is not present.
115+
"""
116+
return self._data.get(key, default)
117+
118+
def set(self, key: str, value: Any) -> None:
119+
"""Write a top-level setting value and persist to disk.
120+
121+
Args:
122+
key: Setting key.
123+
value: Value to store (must be JSON-serializable).
124+
"""
125+
self._data[key] = value
126+
self._save()

local_settings.json.example

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"_comment": "Copy this file to local_settings.json (gitignored) and fill in your absolute paths.",
3+
"plugins": {
4+
"lastfm": {
5+
"data_path": "/absolute/path/to/your/lastfm-tracks.csv"
6+
},
7+
"swarm": {
8+
"swarm_dir": "/absolute/path/to/your/swarm-export/",
9+
"assumptions_file": "/absolute/path/to/your/default_assumptions.json"
10+
}
11+
}
12+
}

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ ignore = [
5757

5858
[tool.mypy]
5959
python_version = "3.9"
60-
files = ["autobiographer.py", "analysis_utils.py", "visualize.py", "record_flythrough.py", "find_checkin.py", "tools"]
60+
files = ["autobiographer.py", "analysis_utils.py", "visualize.py", "record_flythrough.py", "find_checkin.py", "tools", "core"]
6161
ignore_missing_imports = true
6262
warn_unused_ignores = true
6363
warn_return_any = true

0 commit comments

Comments
 (0)