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.
Files changed (46) hide show
  1. {seren_margin-2.3.0/seren_margin.egg-info → seren_margin-2.5.0}/PKG-INFO +1 -1
  2. {seren_margin-2.3.0 → seren_margin-2.5.0}/seren_margin/_version.py +3 -3
  3. {seren_margin-2.3.0 → seren_margin-2.5.0}/seren_margin/app.py +22 -1
  4. {seren_margin-2.3.0 → seren_margin-2.5.0}/seren_margin/config.py +7 -0
  5. {seren_margin-2.3.0 → seren_margin-2.5.0/seren_margin.egg-info}/PKG-INFO +1 -1
  6. seren_margin-2.5.0/seren_margin.egg-info/scm_version.json +8 -0
  7. {seren_margin-2.3.0 → seren_margin-2.5.0}/tests/test_stores_snapshot.py +39 -0
  8. seren_margin-2.3.0/seren_margin.egg-info/scm_version.json +0 -8
  9. {seren_margin-2.3.0 → seren_margin-2.5.0}/LICENSE +0 -0
  10. {seren_margin-2.3.0 → seren_margin-2.5.0}/SerenMargin.pyproj +0 -0
  11. {seren_margin-2.3.0 → seren_margin-2.5.0}/pyproject.toml +0 -0
  12. {seren_margin-2.3.0 → seren_margin-2.5.0}/seren-margin.service.sample +0 -0
  13. {seren_margin-2.3.0 → seren_margin-2.5.0}/seren-margin.yaml.sample +0 -0
  14. {seren_margin-2.3.0 → seren_margin-2.5.0}/seren_margin/__init__.py +0 -0
  15. {seren_margin-2.3.0 → seren_margin-2.5.0}/seren_margin/__main__.py +0 -0
  16. {seren_margin-2.3.0 → seren_margin-2.5.0}/seren_margin/_diag.py +0 -0
  17. {seren_margin-2.3.0 → seren_margin-2.5.0}/seren_margin/bookmark.py +0 -0
  18. {seren_margin-2.3.0 → seren_margin-2.5.0}/seren_margin/mcp/__init__.py +0 -0
  19. {seren_margin-2.3.0 → seren_margin-2.5.0}/seren_margin/mcp/server.py +0 -0
  20. {seren_margin-2.3.0 → seren_margin-2.5.0}/seren_margin/mcp/tools.py +0 -0
  21. {seren_margin-2.3.0 → seren_margin-2.5.0}/seren_margin/mcp-manifest.yaml +0 -0
  22. {seren_margin-2.3.0 → seren_margin-2.5.0}/seren_margin/models.py +0 -0
  23. {seren_margin-2.3.0 → seren_margin-2.5.0}/seren_margin/store.py +0 -0
  24. {seren_margin-2.3.0 → seren_margin-2.5.0}/seren_margin.egg-info/SOURCES.txt +0 -0
  25. {seren_margin-2.3.0 → seren_margin-2.5.0}/seren_margin.egg-info/dependency_links.txt +0 -0
  26. {seren_margin-2.3.0 → seren_margin-2.5.0}/seren_margin.egg-info/entry_points.txt +0 -0
  27. {seren_margin-2.3.0 → seren_margin-2.5.0}/seren_margin.egg-info/requires.txt +0 -0
  28. {seren_margin-2.3.0 → seren_margin-2.5.0}/seren_margin.egg-info/scm_file_list.json +0 -0
  29. {seren_margin-2.3.0 → seren_margin-2.5.0}/seren_margin.egg-info/top_level.txt +0 -0
  30. {seren_margin-2.3.0 → seren_margin-2.5.0}/setup.cfg +0 -0
  31. {seren_margin-2.3.0 → seren_margin-2.5.0}/tests/__init__.py +0 -0
  32. {seren_margin-2.3.0 → seren_margin-2.5.0}/tests/conftest.py +0 -0
  33. {seren_margin-2.3.0 → seren_margin-2.5.0}/tests/test_amend_and_topics.py +0 -0
  34. {seren_margin-2.3.0 → seren_margin-2.5.0}/tests/test_bookmark_and_letters.py +0 -0
  35. {seren_margin-2.3.0 → seren_margin-2.5.0}/tests/test_config.py +0 -0
  36. {seren_margin-2.3.0 → seren_margin-2.5.0}/tests/test_exposure_is_wired.py +0 -0
  37. {seren_margin-2.3.0 → seren_margin-2.5.0}/tests/test_manifest_parity.py +0 -0
  38. {seren_margin-2.3.0 → seren_margin-2.5.0}/tests/test_mcp_fallback.py +0 -0
  39. {seren_margin-2.3.0 → seren_margin-2.5.0}/tests/test_mcp_mount.py +0 -0
  40. {seren_margin-2.3.0 → seren_margin-2.5.0}/tests/test_mcp_tools.py +0 -0
  41. {seren_margin-2.3.0 → seren_margin-2.5.0}/tests/test_optional_bearer.py +0 -0
  42. {seren_margin-2.3.0 → seren_margin-2.5.0}/tests/test_reads_stay_private.py +0 -0
  43. {seren_margin-2.3.0 → seren_margin-2.5.0}/tests/test_search.py +0 -0
  44. {seren_margin-2.3.0 → seren_margin-2.5.0}/tests/test_smoke.py +0 -0
  45. {seren_margin-2.3.0 → seren_margin-2.5.0}/tests/test_stats_content_blind.py +0 -0
  46. {seren_margin-2.3.0 → seren_margin-2.5.0}/tests/test_updates_contract.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: seren-margin
3
- Version: 2.3.0
3
+ Version: 2.5.0
4
4
  Summary: Private notes-to-self for an AI assistant. Standalone, opt-in, opinionated.
5
5
  Author: Chad Roesler
6
6
  License-Expression: AGPL-3.0-or-later
@@ -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.3.0'
22
- __version_tuple__ = version_tuple = (2, 3, 0)
21
+ __version__ = version = '2.5.0'
22
+ __version_tuple__ = version_tuple = (2, 5, 0)
23
23
 
24
- __commit_id__ = commit_id = 'g3339c1e00'
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: seren-margin
3
- Version: 2.3.0
3
+ Version: 2.5.0
4
4
  Summary: Private notes-to-self for an AI assistant. Standalone, opt-in, opinionated.
5
5
  Author: Chad Roesler
6
6
  License-Expression: AGPL-3.0-or-later
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "2.5.0",
3
+ "distance": 0,
4
+ "node": "g41fbfe5984625251f53f11f976b111d6d9efe545",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-10-04"
8
+ }
@@ -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"))
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "2.3.0",
3
- "distance": 0,
4
- "node": "g3339c1e00691cab41191c0b5903aa93cb1df799b",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-10-03"
8
- }
File without changes
File without changes