seren-margin 2.3.0__tar.gz → 2.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {seren_margin-2.3.0/seren_margin.egg-info → seren_margin-2.5.0}/PKG-INFO +1 -1
- {seren_margin-2.3.0 → seren_margin-2.5.0}/seren_margin/_version.py +3 -3
- {seren_margin-2.3.0 → seren_margin-2.5.0}/seren_margin/app.py +22 -1
- {seren_margin-2.3.0 → seren_margin-2.5.0}/seren_margin/config.py +7 -0
- {seren_margin-2.3.0 → seren_margin-2.5.0/seren_margin.egg-info}/PKG-INFO +1 -1
- seren_margin-2.5.0/seren_margin.egg-info/scm_version.json +8 -0
- {seren_margin-2.3.0 → seren_margin-2.5.0}/tests/test_stores_snapshot.py +39 -0
- seren_margin-2.3.0/seren_margin.egg-info/scm_version.json +0 -8
- {seren_margin-2.3.0 → seren_margin-2.5.0}/LICENSE +0 -0
- {seren_margin-2.3.0 → seren_margin-2.5.0}/SerenMargin.pyproj +0 -0
- {seren_margin-2.3.0 → seren_margin-2.5.0}/pyproject.toml +0 -0
- {seren_margin-2.3.0 → seren_margin-2.5.0}/seren-margin.service.sample +0 -0
- {seren_margin-2.3.0 → seren_margin-2.5.0}/seren-margin.yaml.sample +0 -0
- {seren_margin-2.3.0 → seren_margin-2.5.0}/seren_margin/__init__.py +0 -0
- {seren_margin-2.3.0 → seren_margin-2.5.0}/seren_margin/__main__.py +0 -0
- {seren_margin-2.3.0 → seren_margin-2.5.0}/seren_margin/_diag.py +0 -0
- {seren_margin-2.3.0 → seren_margin-2.5.0}/seren_margin/bookmark.py +0 -0
- {seren_margin-2.3.0 → seren_margin-2.5.0}/seren_margin/mcp/__init__.py +0 -0
- {seren_margin-2.3.0 → seren_margin-2.5.0}/seren_margin/mcp/server.py +0 -0
- {seren_margin-2.3.0 → seren_margin-2.5.0}/seren_margin/mcp/tools.py +0 -0
- {seren_margin-2.3.0 → seren_margin-2.5.0}/seren_margin/mcp-manifest.yaml +0 -0
- {seren_margin-2.3.0 → seren_margin-2.5.0}/seren_margin/models.py +0 -0
- {seren_margin-2.3.0 → seren_margin-2.5.0}/seren_margin/store.py +0 -0
- {seren_margin-2.3.0 → seren_margin-2.5.0}/seren_margin.egg-info/SOURCES.txt +0 -0
- {seren_margin-2.3.0 → seren_margin-2.5.0}/seren_margin.egg-info/dependency_links.txt +0 -0
- {seren_margin-2.3.0 → seren_margin-2.5.0}/seren_margin.egg-info/entry_points.txt +0 -0
- {seren_margin-2.3.0 → seren_margin-2.5.0}/seren_margin.egg-info/requires.txt +0 -0
- {seren_margin-2.3.0 → seren_margin-2.5.0}/seren_margin.egg-info/scm_file_list.json +0 -0
- {seren_margin-2.3.0 → seren_margin-2.5.0}/seren_margin.egg-info/top_level.txt +0 -0
- {seren_margin-2.3.0 → seren_margin-2.5.0}/setup.cfg +0 -0
- {seren_margin-2.3.0 → seren_margin-2.5.0}/tests/__init__.py +0 -0
- {seren_margin-2.3.0 → seren_margin-2.5.0}/tests/conftest.py +0 -0
- {seren_margin-2.3.0 → seren_margin-2.5.0}/tests/test_amend_and_topics.py +0 -0
- {seren_margin-2.3.0 → seren_margin-2.5.0}/tests/test_bookmark_and_letters.py +0 -0
- {seren_margin-2.3.0 → seren_margin-2.5.0}/tests/test_config.py +0 -0
- {seren_margin-2.3.0 → seren_margin-2.5.0}/tests/test_exposure_is_wired.py +0 -0
- {seren_margin-2.3.0 → seren_margin-2.5.0}/tests/test_manifest_parity.py +0 -0
- {seren_margin-2.3.0 → seren_margin-2.5.0}/tests/test_mcp_fallback.py +0 -0
- {seren_margin-2.3.0 → seren_margin-2.5.0}/tests/test_mcp_mount.py +0 -0
- {seren_margin-2.3.0 → seren_margin-2.5.0}/tests/test_mcp_tools.py +0 -0
- {seren_margin-2.3.0 → seren_margin-2.5.0}/tests/test_optional_bearer.py +0 -0
- {seren_margin-2.3.0 → seren_margin-2.5.0}/tests/test_reads_stay_private.py +0 -0
- {seren_margin-2.3.0 → seren_margin-2.5.0}/tests/test_search.py +0 -0
- {seren_margin-2.3.0 → seren_margin-2.5.0}/tests/test_smoke.py +0 -0
- {seren_margin-2.3.0 → seren_margin-2.5.0}/tests/test_stats_content_blind.py +0 -0
- {seren_margin-2.3.0 → seren_margin-2.5.0}/tests/test_updates_contract.py +0 -0
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '2.
|
|
22
|
-
__version_tuple__ = version_tuple = (2,
|
|
21
|
+
__version__ = version = '2.5.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (2, 5, 0)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'g41fbfe598'
|
|
@@ -87,6 +87,15 @@ APP_VERSION = get_version("seren-margin", fallback=_fallback_version)
|
|
|
87
87
|
|
|
88
88
|
def create_app(config: Optional[MarginConfig] = None) -> FastAPI:
|
|
89
89
|
cfg = config or load_config()
|
|
90
|
+
# A restore, when the config asks for one (backup.restore_from +
|
|
91
|
+
# restore_reason): into an empty margin only, before the store opens.
|
|
92
|
+
# Refused = the service does not start. No route does this.
|
|
93
|
+
if (cfg.backup.restore_from or "").strip():
|
|
94
|
+
from seren_sinew.stores import Store as _Store, restore_at_startup
|
|
95
|
+
restore_at_startup("seren-margin", [_Store("notes", "sqlite", str(cfg.resolved_db_path()))],
|
|
96
|
+
cfg.backup.restore_from, cfg.backup.restore_reason,
|
|
97
|
+
cfg.resolved_backup_dir() / "seren-margin",
|
|
98
|
+
log=lambda m: diag(f"[seren-margin] {m}"))
|
|
90
99
|
store = MarginStore(cfg.resolved_db_path())
|
|
91
100
|
|
|
92
101
|
@asynccontextmanager
|
|
@@ -230,11 +239,23 @@ def create_app(config: Optional[MarginConfig] = None) -> FastAPI:
|
|
|
230
239
|
notes = None
|
|
231
240
|
return {"version": APP_VERSION, "counts": {"notes": notes}}
|
|
232
241
|
|
|
242
|
+
def _margin_check(restored, manifest, snapshot_dir) -> dict:
|
|
243
|
+
"""A rehearsal's look at a restored COPY: opened as a MarginStore
|
|
244
|
+
(schema and migrations run on the copy) and counted. A count and
|
|
245
|
+
nothing else - a rehearsal's report must not say what a note says."""
|
|
246
|
+
import gc
|
|
247
|
+
st = MarginStore(restored["notes"] / cfg.resolved_db_path().name)
|
|
248
|
+
try:
|
|
249
|
+
return {"counts": {"notes": len(st.list_all(limit=1_000_000))}}
|
|
250
|
+
finally:
|
|
251
|
+
del st
|
|
252
|
+
gc.collect() # its connections, so the scratch copy can be removed
|
|
253
|
+
|
|
233
254
|
app.state.stores = StoreKeeper(
|
|
234
255
|
"seren-margin",
|
|
235
256
|
lambda: [Store("notes", "sqlite", str(cfg.resolved_db_path()),
|
|
236
257
|
"the margin: private notes, letters and the dedication")],
|
|
237
|
-
cfg.resolved_backup_dir(), extra=_margin_extra,
|
|
258
|
+
cfg.resolved_backup_dir(), extra=_margin_extra, check=_margin_check,
|
|
238
259
|
keep_daily=cfg.backup.keep_daily, keep_weekly=cfg.backup.keep_weekly,
|
|
239
260
|
log=lambda m: diag(f"[seren-margin] {m}")) if cfg.backup.enabled else None
|
|
240
261
|
add_store_routes(
|
|
@@ -79,6 +79,13 @@ class BackupConfig(BaseModel):
|
|
|
79
79
|
every_hours: float = 24.0 # 0 = never on its own
|
|
80
80
|
keep_daily: int = 14
|
|
81
81
|
keep_weekly: int = 8
|
|
82
|
+
# THE RESTORE. A snapshot folder (or its .tar.gz) to put back at startup,
|
|
83
|
+
# and why. Only into an EMPTY store: a store that holds anything is never
|
|
84
|
+
# overwritten, and the key is then passed by with a line in the log. There
|
|
85
|
+
# is no route and no tool for this, on purpose - it is these two keys and
|
|
86
|
+
# a restart. See seren_sinew.stores.restore_at_startup.
|
|
87
|
+
restore_from: str = ""
|
|
88
|
+
restore_reason: str = ""
|
|
82
89
|
allow_pull: bool = False
|
|
83
90
|
|
|
84
91
|
|
|
@@ -81,3 +81,42 @@ def test_off_and_no_restore_or_delete(tmp_path):
|
|
|
81
81
|
sid = c.post("/stores/snapshot").json()["snapshot"]["id"]
|
|
82
82
|
for path in ("/stores/restore", f"/stores/snapshots/{sid}"):
|
|
83
83
|
assert c.post(path).status_code in (404, 405) and c.delete(path).status_code in (404, 405)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_a_rehearsal_counts_the_notes_and_says_nothing_of_them(tmp_path):
|
|
87
|
+
"""A restore's dry run (seren_sinew.stores). It needs no allow_pull: the
|
|
88
|
+
copy never leaves the box and the report is a count."""
|
|
89
|
+
from seren_sinew.stores import pack_snapshot
|
|
90
|
+
with _client(tmp_path) as c:
|
|
91
|
+
c.post("/notes", json={"content": SECRET, "topic": "private"})
|
|
92
|
+
snap = c.post("/stores/snapshot").json()["snapshot"]
|
|
93
|
+
c.post("/notes", json={"content": "written after", "topic": "later"})
|
|
94
|
+
r = c.post(f"/stores/snapshots/{snap['id']}/rehearse")
|
|
95
|
+
assert r.status_code == 200 and SECRET not in r.text, r.text
|
|
96
|
+
rep = r.json()
|
|
97
|
+
assert rep["ok"] and rep["check"] == {"counts": {"notes": 1}} and rep["live_store_touched"] is False, rep
|
|
98
|
+
assert len(c.app.state.store.list_all()) == 2
|
|
99
|
+
r = c.post("/stores/rehearse", content=pack_snapshot(Path(snap["path"])))
|
|
100
|
+
assert r.status_code == 200 and r.json()["ok"] and SECRET not in r.text, "a stash can send it back to be checked"
|
|
101
|
+
assert not list((Path(snap["path"]).parent / ".rehearsal").iterdir())
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_a_new_box_restores_the_margin_at_startup_and_a_full_one_is_left_alone(tmp_path):
|
|
105
|
+
"""backup.restore_from + restore_reason, into an empty margin only
|
|
106
|
+
(seren_sinew.stores.restore_at_startup). 3 Oct 2026."""
|
|
107
|
+
import pytest
|
|
108
|
+
from seren_sinew.stores import RestoreRefused
|
|
109
|
+
with _client(tmp_path) as c:
|
|
110
|
+
c.post("/notes", json={"content": SECRET, "topic": "private"})
|
|
111
|
+
snap = c.post("/stores/snapshot").json()["snapshot"]
|
|
112
|
+
|
|
113
|
+
def cfg(where, **backup):
|
|
114
|
+
return MarginConfig(db_path=str(tmp_path / where / "notes.db"),
|
|
115
|
+
backup=BackupConfig(every_hours=0, restore_from=snap["path"], **backup))
|
|
116
|
+
with TestClient(create_app(cfg("new", restore_reason="moving to the cluster"))) as c:
|
|
117
|
+
assert [n.content for n in c.app.state.store.list_all()] == [SECRET]
|
|
118
|
+
c.post("/notes", json={"content": "written on the new box", "topic": "later"})
|
|
119
|
+
with TestClient(create_app(cfg("new", restore_reason="moving to the cluster"))) as c:
|
|
120
|
+
assert len(c.app.state.store.list_all()) == 2, "the second start passes the key by"
|
|
121
|
+
with pytest.raises(RestoreRefused, match="asked for with a reason"):
|
|
122
|
+
create_app(cfg("other"))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|