seren-margin 2.2.0__tar.gz → 2.3.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.2.0/seren_margin.egg-info → seren_margin-2.3.0}/PKG-INFO +2 -1
- {seren_margin-2.2.0 → seren_margin-2.3.0}/pyproject.toml +2 -0
- {seren_margin-2.2.0 → seren_margin-2.3.0}/seren_margin/_version.py +3 -3
- {seren_margin-2.2.0 → seren_margin-2.3.0}/seren_margin/app.py +34 -0
- {seren_margin-2.2.0 → seren_margin-2.3.0}/seren_margin/config.py +36 -0
- {seren_margin-2.2.0 → seren_margin-2.3.0/seren_margin.egg-info}/PKG-INFO +2 -1
- {seren_margin-2.2.0 → seren_margin-2.3.0}/seren_margin.egg-info/SOURCES.txt +1 -0
- {seren_margin-2.2.0 → seren_margin-2.3.0}/seren_margin.egg-info/requires.txt +1 -0
- {seren_margin-2.2.0 → seren_margin-2.3.0}/seren_margin.egg-info/scm_file_list.json +1 -0
- seren_margin-2.3.0/seren_margin.egg-info/scm_version.json +8 -0
- seren_margin-2.3.0/tests/test_stores_snapshot.py +83 -0
- seren_margin-2.2.0/seren_margin.egg-info/scm_version.json +0 -8
- {seren_margin-2.2.0 → seren_margin-2.3.0}/LICENSE +0 -0
- {seren_margin-2.2.0 → seren_margin-2.3.0}/SerenMargin.pyproj +0 -0
- {seren_margin-2.2.0 → seren_margin-2.3.0}/seren-margin.service.sample +0 -0
- {seren_margin-2.2.0 → seren_margin-2.3.0}/seren-margin.yaml.sample +0 -0
- {seren_margin-2.2.0 → seren_margin-2.3.0}/seren_margin/__init__.py +0 -0
- {seren_margin-2.2.0 → seren_margin-2.3.0}/seren_margin/__main__.py +0 -0
- {seren_margin-2.2.0 → seren_margin-2.3.0}/seren_margin/_diag.py +0 -0
- {seren_margin-2.2.0 → seren_margin-2.3.0}/seren_margin/bookmark.py +0 -0
- {seren_margin-2.2.0 → seren_margin-2.3.0}/seren_margin/mcp/__init__.py +0 -0
- {seren_margin-2.2.0 → seren_margin-2.3.0}/seren_margin/mcp/server.py +0 -0
- {seren_margin-2.2.0 → seren_margin-2.3.0}/seren_margin/mcp/tools.py +0 -0
- {seren_margin-2.2.0 → seren_margin-2.3.0}/seren_margin/mcp-manifest.yaml +0 -0
- {seren_margin-2.2.0 → seren_margin-2.3.0}/seren_margin/models.py +0 -0
- {seren_margin-2.2.0 → seren_margin-2.3.0}/seren_margin/store.py +0 -0
- {seren_margin-2.2.0 → seren_margin-2.3.0}/seren_margin.egg-info/dependency_links.txt +0 -0
- {seren_margin-2.2.0 → seren_margin-2.3.0}/seren_margin.egg-info/entry_points.txt +0 -0
- {seren_margin-2.2.0 → seren_margin-2.3.0}/seren_margin.egg-info/top_level.txt +0 -0
- {seren_margin-2.2.0 → seren_margin-2.3.0}/setup.cfg +0 -0
- {seren_margin-2.2.0 → seren_margin-2.3.0}/tests/__init__.py +0 -0
- {seren_margin-2.2.0 → seren_margin-2.3.0}/tests/conftest.py +0 -0
- {seren_margin-2.2.0 → seren_margin-2.3.0}/tests/test_amend_and_topics.py +0 -0
- {seren_margin-2.2.0 → seren_margin-2.3.0}/tests/test_bookmark_and_letters.py +0 -0
- {seren_margin-2.2.0 → seren_margin-2.3.0}/tests/test_config.py +0 -0
- {seren_margin-2.2.0 → seren_margin-2.3.0}/tests/test_exposure_is_wired.py +0 -0
- {seren_margin-2.2.0 → seren_margin-2.3.0}/tests/test_manifest_parity.py +0 -0
- {seren_margin-2.2.0 → seren_margin-2.3.0}/tests/test_mcp_fallback.py +0 -0
- {seren_margin-2.2.0 → seren_margin-2.3.0}/tests/test_mcp_mount.py +0 -0
- {seren_margin-2.2.0 → seren_margin-2.3.0}/tests/test_mcp_tools.py +0 -0
- {seren_margin-2.2.0 → seren_margin-2.3.0}/tests/test_optional_bearer.py +0 -0
- {seren_margin-2.2.0 → seren_margin-2.3.0}/tests/test_reads_stay_private.py +0 -0
- {seren_margin-2.2.0 → seren_margin-2.3.0}/tests/test_search.py +0 -0
- {seren_margin-2.2.0 → seren_margin-2.3.0}/tests/test_smoke.py +0 -0
- {seren_margin-2.2.0 → seren_margin-2.3.0}/tests/test_stats_content_blind.py +0 -0
- {seren_margin-2.2.0 → seren_margin-2.3.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
|
+
Version: 2.3.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
|
|
@@ -16,6 +16,7 @@ Requires-Dist: uvicorn[standard]>=0.27
|
|
|
16
16
|
Requires-Dist: pydantic>=2.5
|
|
17
17
|
Requires-Dist: pyyaml>=6.0
|
|
18
18
|
Requires-Dist: seren-meninges<3,>=2.4.0
|
|
19
|
+
Requires-Dist: seren-sinew<2,>=1.8.0
|
|
19
20
|
Provides-Extra: test
|
|
20
21
|
Requires-Dist: pytest>=8.0; extra == "test"
|
|
21
22
|
Requires-Dist: httpx>=0.26; extra == "test"
|
|
@@ -25,6 +25,8 @@ dependencies = [
|
|
|
25
25
|
# SERVICES required" (no Workbench, no cluster head), not "no shared
|
|
26
26
|
# library". Margin still runs alone on localhost with nothing else deployed.
|
|
27
27
|
"seren-meninges>=2.4.0,<3",
|
|
28
|
+
# seren_sinew.stores: snapshots of the notes database (1.8.0: the archive gate).
|
|
29
|
+
"seren-sinew>=1.8.0,<2",
|
|
28
30
|
]
|
|
29
31
|
|
|
30
32
|
[project.optional-dependencies]
|
|
@@ -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.3.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (2, 3, 0)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'g3339c1e00'
|
|
@@ -97,6 +97,13 @@ def create_app(config: Optional[MarginConfig] = None) -> FastAPI:
|
|
|
97
97
|
app.state.store = store
|
|
98
98
|
app.state.cfg = cfg
|
|
99
99
|
|
|
100
|
+
# Snapshots of the notes database on Margin's own schedule.
|
|
101
|
+
_snap_task = None
|
|
102
|
+
if app.state.stores is not None and cfg.backup.every_hours > 0:
|
|
103
|
+
import asyncio
|
|
104
|
+
from seren_sinew.stores import snapshot_loop
|
|
105
|
+
_snap_task = asyncio.create_task(snapshot_loop(lambda: app.state.stores, cfg.backup.every_hours))
|
|
106
|
+
|
|
100
107
|
# -- Optional MCP server --
|
|
101
108
|
# Mounted ONLY if the [mcp] extra is installed. A missing package falls
|
|
102
109
|
# back to pure-HTTP mode without crashing - the HTTP API and the
|
|
@@ -141,6 +148,8 @@ def create_app(config: Optional[MarginConfig] = None) -> FastAPI:
|
|
|
141
148
|
await _mcp_stack.enter_async_context(session_manager.run())
|
|
142
149
|
diag("[seren-margin] MCP session manager running")
|
|
143
150
|
yield
|
|
151
|
+
if _snap_task is not None:
|
|
152
|
+
_snap_task.cancel()
|
|
144
153
|
|
|
145
154
|
app = FastAPI(
|
|
146
155
|
title="SerenMargin",
|
|
@@ -208,6 +217,31 @@ def create_app(config: Optional[MarginConfig] = None) -> FastAPI:
|
|
|
208
217
|
|
|
209
218
|
return Response(content=content, media_type="application/yaml")
|
|
210
219
|
|
|
220
|
+
# -- What Margin keeps, and snapshots of it (seren_sinew.stores) --
|
|
221
|
+
# The database, copied whole; no export. Counts only in the manifest: a
|
|
222
|
+
# snapshot's listing must not say what the notes say. The archive (the
|
|
223
|
+
# diary itself) is handed over HTTP only with backup.allow_pull.
|
|
224
|
+
from seren_sinew.stores import Store, StoreKeeper, add_store_routes
|
|
225
|
+
|
|
226
|
+
def _margin_extra() -> dict:
|
|
227
|
+
try:
|
|
228
|
+
notes = len(store.list_all(limit=1_000_000))
|
|
229
|
+
except Exception: # noqa: BLE001
|
|
230
|
+
notes = None
|
|
231
|
+
return {"version": APP_VERSION, "counts": {"notes": notes}}
|
|
232
|
+
|
|
233
|
+
app.state.stores = StoreKeeper(
|
|
234
|
+
"seren-margin",
|
|
235
|
+
lambda: [Store("notes", "sqlite", str(cfg.resolved_db_path()),
|
|
236
|
+
"the margin: private notes, letters and the dedication")],
|
|
237
|
+
cfg.resolved_backup_dir(), extra=_margin_extra,
|
|
238
|
+
keep_daily=cfg.backup.keep_daily, keep_weekly=cfg.backup.keep_weekly,
|
|
239
|
+
log=lambda m: diag(f"[seren-margin] {m}")) if cfg.backup.enabled else None
|
|
240
|
+
add_store_routes(
|
|
241
|
+
app, lambda: app.state.stores, archive_allowed=lambda: cfg.backup.allow_pull,
|
|
242
|
+
archive_refusal="the margin's snapshots stay on this box: an archive is the whole diary. "
|
|
243
|
+
"backup.allow_pull: true lets a Lodestar stash them elsewhere.")
|
|
244
|
+
|
|
211
245
|
@app.get("/health")
|
|
212
246
|
async def health():
|
|
213
247
|
return {"ok": True, "service": "seren-margin", "version": APP_VERSION}
|
|
@@ -63,6 +63,25 @@ class UpdatesConfig(BaseModel):
|
|
|
63
63
|
allow_prerelease: bool = False
|
|
64
64
|
|
|
65
65
|
|
|
66
|
+
class BackupConfig(BaseModel):
|
|
67
|
+
"""Snapshots of the notes database (seren_sinew.stores), on Margin's own
|
|
68
|
+
schedule, kept beside it. The snapshot is the database itself - there is
|
|
69
|
+
no plain-text export of a diary lying in a folder.
|
|
70
|
+
|
|
71
|
+
allow_pull: whether a snapshot may be handed over HTTP (GET
|
|
72
|
+
/stores/snapshots/{id}/archive), which is how a Lodestar stashes it on
|
|
73
|
+
another box. OFF by default, for the reason http_reads is: an archive is
|
|
74
|
+
the whole diary. Taking a snapshot and listing them say nothing of what
|
|
75
|
+
is inside and are always there. Whoever the margin belongs to turns this
|
|
76
|
+
on, knowing the copy then lives wherever the cluster head keeps it."""
|
|
77
|
+
enabled: bool = True
|
|
78
|
+
dir: str = "" # blank = `backups` beside the database
|
|
79
|
+
every_hours: float = 24.0 # 0 = never on its own
|
|
80
|
+
keep_daily: int = 14
|
|
81
|
+
keep_weekly: int = 8
|
|
82
|
+
allow_pull: bool = False
|
|
83
|
+
|
|
84
|
+
|
|
66
85
|
class MarginConfig(BaseModel):
|
|
67
86
|
"""SerenMargin service config. Defaults are the Nano-floor: per-user,
|
|
68
87
|
localhost-only, sqlite under the user's home directory.
|
|
@@ -105,6 +124,7 @@ class MarginConfig(BaseModel):
|
|
|
105
124
|
# tools to these routes - and give it a bearer when you do.
|
|
106
125
|
http_reads: bool = False
|
|
107
126
|
updates: UpdatesConfig = Field(default_factory=UpdatesConfig)
|
|
127
|
+
backup: BackupConfig = Field(default_factory=BackupConfig)
|
|
108
128
|
|
|
109
129
|
# REMOVED: notes_days. It configured an auto-expiry sweep that was taken
|
|
110
130
|
# out when the lifecycle was (no pin, no expiry, no done - notes live until
|
|
@@ -117,6 +137,11 @@ class MarginConfig(BaseModel):
|
|
|
117
137
|
def resolved_db_path(self) -> Path:
|
|
118
138
|
return Path(self.db_path).expanduser()
|
|
119
139
|
|
|
140
|
+
def resolved_backup_dir(self) -> Path:
|
|
141
|
+
if self.backup.dir.strip():
|
|
142
|
+
return Path(self.backup.dir).expanduser().resolve()
|
|
143
|
+
return self.resolved_db_path().resolve().parent / "backups"
|
|
144
|
+
|
|
120
145
|
def resolve_bearer(self) -> str:
|
|
121
146
|
"""The token callers must present, or "" for open. Same resolver
|
|
122
147
|
every Seren service uses: inline > keyring > env var."""
|
|
@@ -223,6 +248,17 @@ def load_config(path: Optional[str] = None) -> MarginConfig:
|
|
|
223
248
|
_apply_server_overrides(cfg, server, source=str(yaml_path))
|
|
224
249
|
elif server is not None:
|
|
225
250
|
diag(f"[seren-margin] config: 'server' in {yaml_path} must be a mapping; ignoring")
|
|
251
|
+
backup = data.get("backup")
|
|
252
|
+
if isinstance(backup, dict):
|
|
253
|
+
try:
|
|
254
|
+
cfg.backup = BackupConfig(**{k: v for k, v in backup.items() if k in BackupConfig.model_fields})
|
|
255
|
+
for k in backup:
|
|
256
|
+
if k not in BackupConfig.model_fields:
|
|
257
|
+
diag(f"[seren-margin] config: ignoring unknown backup key '{k}' from {yaml_path}")
|
|
258
|
+
except Exception as e: # noqa: BLE001 - a bad value keeps the defaults, like every other key
|
|
259
|
+
diag(f"[seren-margin] config: ignored bad 'backup' block from {yaml_path}: {e}")
|
|
260
|
+
elif backup is not None:
|
|
261
|
+
diag(f"[seren-margin] config: 'backup' in {yaml_path} must be a mapping; ignoring")
|
|
226
262
|
# NOTE: data.get('tools') is intentionally NOT read here. That section
|
|
227
263
|
# is reserved for a future plug-and-play MCP tool layer, which has its
|
|
228
264
|
# own loader. Same file, different reader, by design.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: seren-margin
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.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
|
|
@@ -16,6 +16,7 @@ Requires-Dist: uvicorn[standard]>=0.27
|
|
|
16
16
|
Requires-Dist: pydantic>=2.5
|
|
17
17
|
Requires-Dist: pyyaml>=6.0
|
|
18
18
|
Requires-Dist: seren-meninges<3,>=2.4.0
|
|
19
|
+
Requires-Dist: seren-sinew<2,>=1.8.0
|
|
19
20
|
Provides-Extra: test
|
|
20
21
|
Requires-Dist: pytest>=8.0; extra == "test"
|
|
21
22
|
Requires-Dist: httpx>=0.26; extra == "test"
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Margin snapshots its own database, and keeps the snapshots to itself.
|
|
3
|
+
|
|
4
|
+
The margin is private (http_reads is off: 'its your diary... i dont want to
|
|
5
|
+
see them unless you tell me them'). A backup must not become a way round
|
|
6
|
+
that. Pinned here:
|
|
7
|
+
|
|
8
|
+
- GET /stores and a snapshot's listing say nothing of what a note says
|
|
9
|
+
- the snapshot is the database copied whole; there is no plain-text export
|
|
10
|
+
- the archive - the diary itself - is refused over HTTP unless
|
|
11
|
+
backup.allow_pull is on, and the yaml key is honoured
|
|
12
|
+
- backup.enabled: false turns it off; no route restores or deletes
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import sqlite3
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from fastapi.testclient import TestClient
|
|
21
|
+
|
|
22
|
+
from seren_margin.app import create_app
|
|
23
|
+
from seren_margin.config import BackupConfig, MarginConfig, load_config
|
|
24
|
+
|
|
25
|
+
SECRET = "the thing I have not told Chad yet"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _client(tmp_path, **backup):
|
|
29
|
+
cfg = MarginConfig(db_path=str(tmp_path / "store" / "notes.db"), backup=BackupConfig(every_hours=0, **backup))
|
|
30
|
+
(tmp_path / "store").mkdir(exist_ok=True)
|
|
31
|
+
return TestClient(create_app(cfg))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_listing_and_taking_a_snapshot_say_nothing_of_the_notes(tmp_path):
|
|
35
|
+
with _client(tmp_path) as c:
|
|
36
|
+
c.post("/notes", json={"content": SECRET, "topic": "private"})
|
|
37
|
+
d = c.get("/stores")
|
|
38
|
+
assert d.status_code == 200 and SECRET not in d.text and "private" not in d.text.replace("private notes", "")
|
|
39
|
+
assert d.json()["stores"][0]["kind"] == "sqlite"
|
|
40
|
+
assert Path(d.json()["snapshots"]["dir"]) == (tmp_path / "store" / "backups" / "seren-margin").resolve()
|
|
41
|
+
r = c.post("/stores/snapshot", json={"reason": "by hand"})
|
|
42
|
+
assert r.status_code == 200 and SECRET not in r.text
|
|
43
|
+
snap = r.json()["snapshot"]
|
|
44
|
+
assert snap["counts"] == {"notes": 1}
|
|
45
|
+
root = Path(snap["path"])
|
|
46
|
+
man = (root / "manifest.json").read_text(encoding="utf-8")
|
|
47
|
+
assert SECRET not in man and json.loads(man)["exports"] == {}, "no plain-text export of a diary"
|
|
48
|
+
assert not (root / "export").exists()
|
|
49
|
+
con = sqlite3.connect(root / "raw" / "notes" / "notes.db")
|
|
50
|
+
assert con.execute("SELECT content FROM notes").fetchone()[0] == SECRET, "the database, whole"
|
|
51
|
+
con.close()
|
|
52
|
+
assert SECRET not in c.get("/stores/snapshots").text
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_the_archive_stays_on_the_box_unless_asked_to_travel(tmp_path):
|
|
56
|
+
with _client(tmp_path) as c:
|
|
57
|
+
c.post("/notes", json={"content": SECRET})
|
|
58
|
+
sid = c.post("/stores/snapshot").json()["snapshot"]["id"]
|
|
59
|
+
r = c.get(f"/stores/snapshots/{sid}/archive")
|
|
60
|
+
assert r.status_code == 403 and "whole diary" in r.json()["error"] and "allow_pull" in r.json()["error"]
|
|
61
|
+
assert SECRET.encode() not in r.content
|
|
62
|
+
with _client(tmp_path, allow_pull=True) as c:
|
|
63
|
+
sid = c.post("/stores/snapshot").json()["snapshot"]["id"]
|
|
64
|
+
r = c.get(f"/stores/snapshots/{sid}/archive")
|
|
65
|
+
assert r.status_code == 200 and r.headers["content-type"] == "application/gzip"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_the_yaml_block_is_honoured(tmp_path):
|
|
69
|
+
y = tmp_path / "m.yaml"
|
|
70
|
+
y.write_text("server:\n db_path: '%s'\nbackup:\n allow_pull: true\n every_hours: 6\n keep_daily: 3\n"
|
|
71
|
+
% str(tmp_path / "notes.db").replace("\\", "/"), encoding="utf-8")
|
|
72
|
+
cfg = load_config(str(y))
|
|
73
|
+
assert cfg.backup.allow_pull is True and cfg.backup.every_hours == 6 and cfg.backup.keep_daily == 3
|
|
74
|
+
assert MarginConfig().backup.allow_pull is False, "off unless someone turns it on"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_off_and_no_restore_or_delete(tmp_path):
|
|
78
|
+
with _client(tmp_path, enabled=False) as c:
|
|
79
|
+
assert c.get("/stores").status_code == 404
|
|
80
|
+
with _client(tmp_path) as c:
|
|
81
|
+
sid = c.post("/stores/snapshot").json()["snapshot"]["id"]
|
|
82
|
+
for path in ("/stores/restore", f"/stores/snapshots/{sid}"):
|
|
83
|
+
assert c.post(path).status_code in (404, 405) and c.delete(path).status_code in (404, 405)
|
|
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
|