margin 0.2.1__py3-none-any.whl
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.
- margin/__init__.py +0 -0
- margin/batch.py +41 -0
- margin/claude.py +89 -0
- margin/cli.py +294 -0
- margin/config.py +188 -0
- margin/fold.py +750 -0
- margin/images.py +66 -0
- margin/inbox.py +335 -0
- margin/outline.py +282 -0
- margin/patches.py +243 -0
- margin/pinch_check.py +363 -0
- margin/skills/margin/SKILL.md +95 -0
- margin/store.py +186 -0
- margin/sync.py +666 -0
- margin-0.2.1.dist-info/METADATA +113 -0
- margin-0.2.1.dist-info/RECORD +20 -0
- margin-0.2.1.dist-info/WHEEL +5 -0
- margin-0.2.1.dist-info/entry_points.txt +2 -0
- margin-0.2.1.dist-info/licenses/LICENSE +21 -0
- margin-0.2.1.dist-info/top_level.txt +1 -0
margin/images.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Pictures in a published file: a local image a markdown file shows is uploaded to Outline.
|
|
2
|
+
|
|
3
|
+
A file in the repository refers to its pictures by a relative path (``),
|
|
4
|
+
which works on GitHub and in an editor but means nothing to Outline. Before a body is sent, each
|
|
5
|
+
such reference is uploaded once as an attachment of that document and the reference is pointed at
|
|
6
|
+
it. The repository file keeps its relative path.
|
|
7
|
+
|
|
8
|
+
⭐ A PICTURE IS PART OF THE FILE. Its bytes count in what margin compares, so a changed picture is
|
|
9
|
+
published even when the markdown did not change, and an unchanged picture is never uploaded twice
|
|
10
|
+
(each upload is remembered by document and content).
|
|
11
|
+
|
|
12
|
+
⭐ ATTACHED TO ITS DOCUMENT. An attachment belongs to the document it was uploaded for, so everyone
|
|
13
|
+
who can read that document can see it. One uploaded for another document (the private draft margin
|
|
14
|
+
uses to learn Outline's markdown) would be visible to margin's own account only.
|
|
15
|
+
|
|
16
|
+
Only images that are files next to the markdown are touched: a web address, an address Outline
|
|
17
|
+
already serves, a data: URI or a missing file is left as it is.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
import re
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
TYPES = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif",
|
|
26
|
+
".webp": "image/webp", ".svg": "image/svg+xml"}
|
|
27
|
+
|
|
28
|
+
#  and , the path optionally in <angle brackets>
|
|
29
|
+
IMAGE = re.compile(r'!\[(?P<alt>[^\]]*)\]\(\s*(?P<path><[^>]+>|[^)\s]+)(?P<title>\s+"[^"]*")?\s*\)')
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _target(ref: str) -> str:
|
|
33
|
+
return ref[1:-1] if ref.startswith("<") and ref.endswith(">") else ref
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def local(body: str, base: Path) -> list[tuple[str, Path]]:
|
|
37
|
+
"""(reference as written, file) for each local image the body shows, in order, once each."""
|
|
38
|
+
out, seen = [], set()
|
|
39
|
+
for m in IMAGE.finditer(body):
|
|
40
|
+
ref = m.group("path")
|
|
41
|
+
target = _target(ref)
|
|
42
|
+
if re.match(r"^[a-z][a-z0-9+.-]*:", target, re.I) or target.startswith(("/", "#")):
|
|
43
|
+
continue # a web address, data:, or one Outline already serves
|
|
44
|
+
path = (base / target.split("#", 1)[0].split("?", 1)[0]).resolve()
|
|
45
|
+
if path.suffix.lower() in TYPES and path.is_file() and ref not in seen:
|
|
46
|
+
seen.add(ref)
|
|
47
|
+
out.append((ref, path))
|
|
48
|
+
return out
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def digest(body: str, base: Path) -> str:
|
|
52
|
+
"""A line per local image with its content's hash, or "" when there is none. Added to the
|
|
53
|
+
file's text where margin compares it, so a changed picture counts as a changed file."""
|
|
54
|
+
lines = [f"<!-- margin image {ref} {hashlib.sha256(p.read_bytes()).hexdigest()} -->"
|
|
55
|
+
for ref, p in local(body, base)]
|
|
56
|
+
return "\n" + "\n".join(lines) if lines else ""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def point(body: str, urls: dict[str, str]) -> str:
|
|
60
|
+
"""The body with each reference in `urls` replaced by its Outline address."""
|
|
61
|
+
def swap(m: re.Match) -> str:
|
|
62
|
+
url = urls.get(m.group("path"))
|
|
63
|
+
if url is None:
|
|
64
|
+
return m.group(0)
|
|
65
|
+
return f" or ''})"
|
|
66
|
+
return IMAGE.sub(swap, body)
|
margin/inbox.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Comments in: the webhook receiver, the catch-up poll, and handing ready batches to a notifier.
|
|
2
|
+
|
|
3
|
+
The webhook is the fast path. The poll is the guarantee: every `poll_seconds` it asks Outline for
|
|
4
|
+
comments newer than the newest one it has seen, so a comment made while this process was down or
|
|
5
|
+
while the Mac slept is still delivered. Both paths write to the same table, keyed by comment id,
|
|
6
|
+
so a comment that arrives both ways is recorded once.
|
|
7
|
+
|
|
8
|
+
The agent's own comments are recorded as already delivered and with no route, so an app can show
|
|
9
|
+
a whole thread while the agent never wakes itself.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import hmac
|
|
15
|
+
import json
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
import uuid
|
|
21
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
22
|
+
|
|
23
|
+
from . import batch
|
|
24
|
+
from .config import Config
|
|
25
|
+
from .outline import Outline, OutlineError, comment_text, doc_url, mentions
|
|
26
|
+
from .store import Store
|
|
27
|
+
|
|
28
|
+
EVENTS = ["comments.create", "comments.update", "documents.update"]
|
|
29
|
+
#: how many characters before a passage are kept to find it again
|
|
30
|
+
BEFORE = 40
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def anchor_before(doc: dict | None, comment_id: str) -> str | None:
|
|
34
|
+
"""The text before a comment's passage in its paragraph (at most BEFORE characters, "" when the
|
|
35
|
+
passage opens the paragraph), or None when the document has no mark for the comment. A passage's
|
|
36
|
+
words alone can appear many times in a document ("search", "Network"); with the words before it
|
|
37
|
+
a reader finds the place the comment is on."""
|
|
38
|
+
found: list[str] = []
|
|
39
|
+
|
|
40
|
+
def text_of(n: dict) -> str:
|
|
41
|
+
if n.get("type") == "text":
|
|
42
|
+
return n.get("text") or ""
|
|
43
|
+
if n.get("type") == "mention":
|
|
44
|
+
return "@" + ((n.get("attrs") or {}).get("label") or "")
|
|
45
|
+
return "".join(text_of(c) for c in n.get("content") or [] if isinstance(c, dict))
|
|
46
|
+
|
|
47
|
+
def walk(n) -> None:
|
|
48
|
+
if not isinstance(n, dict) or found:
|
|
49
|
+
return
|
|
50
|
+
kids = [c for c in n.get("content") or [] if isinstance(c, dict)]
|
|
51
|
+
if any(c.get("type") == "text" for c in kids):
|
|
52
|
+
# a block of running text: its marked child opens the passage
|
|
53
|
+
seen = ""
|
|
54
|
+
for c in kids:
|
|
55
|
+
if any(m.get("type") == "comment" and (m.get("attrs") or {}).get("id") == comment_id
|
|
56
|
+
for m in c.get("marks") or []):
|
|
57
|
+
found.append(seen[-BEFORE:])
|
|
58
|
+
return
|
|
59
|
+
seen += text_of(c)
|
|
60
|
+
return
|
|
61
|
+
for c in kids:
|
|
62
|
+
walk(c)
|
|
63
|
+
|
|
64
|
+
walk(doc or {})
|
|
65
|
+
return found[0] if found else None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def verify(secret: str | None, header: str | None, body: bytes, tolerance: float = 300) -> bool:
|
|
69
|
+
"""Outline-Signature is `t=<ms>,s=<hex>`, the hex being HMAC-SHA256 of `t + "." + body`."""
|
|
70
|
+
if not secret:
|
|
71
|
+
return True
|
|
72
|
+
if not header:
|
|
73
|
+
return False
|
|
74
|
+
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
|
|
75
|
+
t, s = parts.get("t"), parts.get("s")
|
|
76
|
+
if not t or not s:
|
|
77
|
+
return False
|
|
78
|
+
want = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
|
|
79
|
+
try:
|
|
80
|
+
fresh = abs(time.time() - int(t) / 1000) <= tolerance
|
|
81
|
+
except ValueError:
|
|
82
|
+
return False
|
|
83
|
+
return fresh and hmac.compare_digest(want, s)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Inbox:
|
|
87
|
+
def __init__(self, cfg: Config, outline: Outline, store: Store):
|
|
88
|
+
self.cfg, self.o, self.s = cfg, outline, store
|
|
89
|
+
self.agent_id = outline.me()["id"]
|
|
90
|
+
self.repos = {r.name: r for r in cfg.repos}
|
|
91
|
+
self.lock = threading.Lock()
|
|
92
|
+
|
|
93
|
+
# ---- recording
|
|
94
|
+
def route_of(self, document_id: str) -> str | None:
|
|
95
|
+
"""Where a document's comments go: the route `routes` names for its path, else whoever last
|
|
96
|
+
linked it (recorded by `margin link`), else the repo's route."""
|
|
97
|
+
row = self.s.doc_by_id(document_id)
|
|
98
|
+
repo = self.repos.get(row["repo"]) if row else None
|
|
99
|
+
if repo is None:
|
|
100
|
+
return None
|
|
101
|
+
return repo.named_route(row["path"]) or self.s.get(f"author:{document_id}") or repo.route
|
|
102
|
+
|
|
103
|
+
def picture_of(self, document_id: str, comment_id: str) -> str | None:
|
|
104
|
+
"""The picture a comment is attached to, by its caption, or None. A comment on a picture has
|
|
105
|
+
no anchor text (Outline gives an empty one), so without this it read as a comment on the
|
|
106
|
+
whole document and nobody could tell which picture it meant (2026-10-13)."""
|
|
107
|
+
try:
|
|
108
|
+
doc = self.o.structure(document_id)
|
|
109
|
+
except Exception: # noqa: BLE001 - the comment is kept either way
|
|
110
|
+
return None
|
|
111
|
+
found: list[str] = []
|
|
112
|
+
|
|
113
|
+
def walk(n, heading):
|
|
114
|
+
if not isinstance(n, dict):
|
|
115
|
+
return heading
|
|
116
|
+
if n.get("type") == "heading":
|
|
117
|
+
heading = "".join(c.get("text", "") for c in n.get("content") or [] if isinstance(c, dict))
|
|
118
|
+
if n.get("type") == "image" and any(
|
|
119
|
+
m.get("type") == "comment" and (m.get("attrs") or {}).get("id") == comment_id
|
|
120
|
+
for m in (n.get("attrs") or {}).get("marks") or []):
|
|
121
|
+
found.append((n.get("attrs") or {}).get("alt") or heading or "a picture")
|
|
122
|
+
for c in n.get("content") or []:
|
|
123
|
+
heading = walk(c, heading)
|
|
124
|
+
return heading
|
|
125
|
+
|
|
126
|
+
walk(doc or {}, None)
|
|
127
|
+
return f"the picture {found[0]}" if found else None
|
|
128
|
+
|
|
129
|
+
def before_of(self, document_id: str, comment_id: str) -> str | None:
|
|
130
|
+
"""Where a comment's passage sits (anchor_before), read from the document's marks."""
|
|
131
|
+
try:
|
|
132
|
+
return anchor_before(self.o.structure(document_id), comment_id)
|
|
133
|
+
except Exception: # noqa: BLE001 - the comment is kept either way
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
def place(self, document_id: str) -> tuple[int, int]:
|
|
137
|
+
"""Record again where each thread's passage sits in a document, after its text changed
|
|
138
|
+
(margin patched it, or a person edited it in Outline): (placed, lost). A passage whose text
|
|
139
|
+
was rewritten loses its mark in Outline, and a reader must not guess a place for it."""
|
|
140
|
+
try:
|
|
141
|
+
doc = self.o.structure(document_id)
|
|
142
|
+
except Exception: # noqa: BLE001 - try again at the next change
|
|
143
|
+
return 0, 0
|
|
144
|
+
placed = lost = 0
|
|
145
|
+
for cid in self.s.anchored(document_id):
|
|
146
|
+
before = anchor_before(doc, cid)
|
|
147
|
+
self.s.set_place(cid, before)
|
|
148
|
+
placed, lost = placed + (before is not None), lost + (before is None)
|
|
149
|
+
return placed, lost
|
|
150
|
+
|
|
151
|
+
def record(self, c: dict, via: str) -> bool:
|
|
152
|
+
own = c.get("createdById") == self.agent_id
|
|
153
|
+
rec = {
|
|
154
|
+
"id": c["id"], "document_id": c["documentId"], "parent_id": c.get("parentCommentId"),
|
|
155
|
+
"author_id": c.get("createdById"), "author": (c.get("createdBy") or {}).get("name"),
|
|
156
|
+
"text": comment_text(c.get("data")),
|
|
157
|
+
"anchor": c.get("anchorText") or (None if c.get("parentCommentId")
|
|
158
|
+
else self.picture_of(c["documentId"], c["id"])),
|
|
159
|
+
"created_at": c["createdAt"], "mentions_agent": self.agent_id in mentions(c.get("data")),
|
|
160
|
+
"route": None if own else self.route_of(c["documentId"]), "via": via,
|
|
161
|
+
"resolved_at": c.get("resolvedAt"),
|
|
162
|
+
"anchor_before": (self.before_of(c["documentId"], c["id"])
|
|
163
|
+
if c.get("anchorText") and not c.get("parentCommentId") else None),
|
|
164
|
+
# the agent's own comment is kept for reading a thread, and is never work for it
|
|
165
|
+
"delivered_at": time.time() if own else None,
|
|
166
|
+
}
|
|
167
|
+
with self.lock:
|
|
168
|
+
return self.s.add_comment(rec) and not own
|
|
169
|
+
|
|
170
|
+
def on_webhook(self, event: dict) -> str:
|
|
171
|
+
kind = event.get("event")
|
|
172
|
+
payload = event.get("payload") or {}
|
|
173
|
+
if kind == "documents.update":
|
|
174
|
+
if payload.get("id"):
|
|
175
|
+
self.s.put(f"dirty:{payload['id']}", "1")
|
|
176
|
+
return "noted"
|
|
177
|
+
if kind not in ("comments.create", "comments.update"):
|
|
178
|
+
return "ignored"
|
|
179
|
+
model = payload.get("model")
|
|
180
|
+
if not model or model.get("anchorText") is None:
|
|
181
|
+
try: # the webhook body has no anchor text; ask for it
|
|
182
|
+
model = self.o.comment(payload.get("id") or model["id"])
|
|
183
|
+
except (OutlineError, KeyError, TypeError):
|
|
184
|
+
pass
|
|
185
|
+
if not model:
|
|
186
|
+
return "no model"
|
|
187
|
+
if kind == "comments.update":
|
|
188
|
+
with self.lock:
|
|
189
|
+
self.s.db.execute("UPDATE comment SET text=? WHERE id=? AND delivered_at IS NULL",
|
|
190
|
+
(comment_text(model.get("data")), model["id"]))
|
|
191
|
+
# a resolve or a reopen arrives as an update of the thread's first comment
|
|
192
|
+
self.s.set_resolved(model["id"], model.get("resolvedAt"))
|
|
193
|
+
return "updated"
|
|
194
|
+
return "recorded" if self.record(model, "webhook") else "known"
|
|
195
|
+
|
|
196
|
+
# ---- the catch-up poll
|
|
197
|
+
def poll(self) -> int:
|
|
198
|
+
n = 0
|
|
199
|
+
for repo in self.cfg.repos:
|
|
200
|
+
cid = self.s.get(f"collection:{repo.name}")
|
|
201
|
+
if not cid:
|
|
202
|
+
continue
|
|
203
|
+
key = f"poll:{repo.name}"
|
|
204
|
+
mark = self.s.get(key)
|
|
205
|
+
newest = mark
|
|
206
|
+
for c in self.o.comments(collection_id=cid):
|
|
207
|
+
if mark and c["createdAt"] <= mark:
|
|
208
|
+
break
|
|
209
|
+
newest = max(newest or "", c["createdAt"])
|
|
210
|
+
if mark is None:
|
|
211
|
+
continue # first run: learn where we are, deliver nothing old
|
|
212
|
+
n += self.record(c, "poll")
|
|
213
|
+
if mark is None and newest is None:
|
|
214
|
+
# first run on a collection with no comments yet: start from now, or the next
|
|
215
|
+
# pass would think it is the first run again and skip the first comments
|
|
216
|
+
newest = time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime())
|
|
217
|
+
if newest:
|
|
218
|
+
self.s.put(key, newest)
|
|
219
|
+
return n
|
|
220
|
+
|
|
221
|
+
# ---- delivery
|
|
222
|
+
def render(self, route: str, rows) -> dict:
|
|
223
|
+
items, lines = [], []
|
|
224
|
+
by_doc: dict[str, list] = {}
|
|
225
|
+
for r in rows:
|
|
226
|
+
by_doc.setdefault(r["document_id"], []).append(r)
|
|
227
|
+
for doc_id, rs in by_doc.items():
|
|
228
|
+
row = self.s.doc_by_id(doc_id)
|
|
229
|
+
try:
|
|
230
|
+
d = self.o.document(doc_id)
|
|
231
|
+
title, url = d["title"], doc_url(self.cfg.url, d)
|
|
232
|
+
except OutlineError:
|
|
233
|
+
title, url = doc_id, ""
|
|
234
|
+
path = f"{row['repo']}/{row['path']}" if row else ""
|
|
235
|
+
lines.append(f"### {title}\n{path} {url}".rstrip())
|
|
236
|
+
for r in rs:
|
|
237
|
+
where = f'on "{r["anchor"]}"' if r["anchor"] else (
|
|
238
|
+
f"reply in thread {r['parent_id']}" if r["parent_id"] else "on the document")
|
|
239
|
+
lines.append(f"- [{r['id']}] {r['author']} {where}:\n {r['text']}")
|
|
240
|
+
items.append({**dict(r), "doc_title": title, "doc_url": url, "path": path})
|
|
241
|
+
lines.append("")
|
|
242
|
+
text = (f"{len(rows)} new comment(s) in Outline.\n\n" + "\n".join(lines) +
|
|
243
|
+
"\nAnswer each one in its thread: `margin reply <comment-id> \"...\"`. "
|
|
244
|
+
"Change the repo file when the comment asks for a change, then `margin sync`. "
|
|
245
|
+
"`margin resolve <comment-id>` once it is settled.")
|
|
246
|
+
first = self.s.doc_by_id(rows[0]["document_id"]) if rows else None
|
|
247
|
+
repo = self.repos.get(first["repo"]) if first else None
|
|
248
|
+
return {"route": route, "comments": items, "text": text,
|
|
249
|
+
"repo": repo.name if repo else "", "repo_path": str(repo.path) if repo else ""}
|
|
250
|
+
|
|
251
|
+
def deliver(self, now: float | None = None) -> list[str]:
|
|
252
|
+
now = time.time() if now is None else now
|
|
253
|
+
sent = []
|
|
254
|
+
with self.lock:
|
|
255
|
+
due = batch.ready(self.s.pending(), now, self.cfg.quiet_seconds, self.cfg.cap_seconds)
|
|
256
|
+
for route, rows in due.items():
|
|
257
|
+
msg = self.render(route, rows)
|
|
258
|
+
bid = uuid.uuid4().hex[:12]
|
|
259
|
+
msg["batch_id"] = bid
|
|
260
|
+
if self.notify(route, msg):
|
|
261
|
+
with self.lock:
|
|
262
|
+
self.s.delivered([r["id"] for r in rows], bid)
|
|
263
|
+
sent.append(route)
|
|
264
|
+
return sent
|
|
265
|
+
|
|
266
|
+
def notify(self, route: str, msg: dict) -> bool:
|
|
267
|
+
if not self.cfg.notify_command:
|
|
268
|
+
print(msg["text"], flush=True)
|
|
269
|
+
return True
|
|
270
|
+
subs = {"{route}": route, "{text}": msg["text"], "{repo}": msg.get("repo", ""),
|
|
271
|
+
"{repo_path}": msg.get("repo_path", "")}
|
|
272
|
+
argv = []
|
|
273
|
+
for a in self.cfg.notify_command:
|
|
274
|
+
for k, v in subs.items():
|
|
275
|
+
a = a.replace(k, v)
|
|
276
|
+
argv.append(a)
|
|
277
|
+
p = subprocess.run(argv, input=json.dumps(msg), text=True, capture_output=True,
|
|
278
|
+
cwd=self.cfg.notify_cwd)
|
|
279
|
+
if p.returncode != 0:
|
|
280
|
+
print(f"notifier failed for {route} ({p.returncode}): {p.stderr.strip()[:300]}",
|
|
281
|
+
file=sys.stderr, flush=True)
|
|
282
|
+
return p.returncode == 0
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def serve(inbox: Inbox, sync_pass=None, sync_every: float = 60.0) -> None:
|
|
286
|
+
"""Run the webhook receiver, then loop: deliver ready batches, poll, and sync."""
|
|
287
|
+
host, port = inbox.cfg.listen.rsplit(":", 1)
|
|
288
|
+
secret = inbox.cfg.secret()
|
|
289
|
+
|
|
290
|
+
class Handler(BaseHTTPRequestHandler):
|
|
291
|
+
def do_POST(self):
|
|
292
|
+
body = self.rfile.read(int(self.headers.get("Content-Length") or 0))
|
|
293
|
+
if self.path.rstrip("/") != "/outline" or not verify(
|
|
294
|
+
secret, self.headers.get("Outline-Signature"), body):
|
|
295
|
+
self.send_response(401)
|
|
296
|
+
self.end_headers()
|
|
297
|
+
return
|
|
298
|
+
try:
|
|
299
|
+
result = inbox.on_webhook(json.loads(body))
|
|
300
|
+
code = 200
|
|
301
|
+
except Exception as e: # never let one bad event stop the server
|
|
302
|
+
result, code = f"error: {e}", 500
|
|
303
|
+
self.send_response(code)
|
|
304
|
+
self.send_header("Content-Type", "text/plain")
|
|
305
|
+
self.end_headers()
|
|
306
|
+
self.wfile.write(result.encode())
|
|
307
|
+
|
|
308
|
+
def do_GET(self):
|
|
309
|
+
ok = self.path.rstrip("/") == "/health"
|
|
310
|
+
self.send_response(200 if ok else 404)
|
|
311
|
+
self.end_headers()
|
|
312
|
+
if ok:
|
|
313
|
+
self.wfile.write(json.dumps({"ok": True, "pending": len(inbox.s.pending())}).encode())
|
|
314
|
+
|
|
315
|
+
def log_message(self, *a):
|
|
316
|
+
pass
|
|
317
|
+
|
|
318
|
+
httpd = ThreadingHTTPServer((host, int(port)), Handler)
|
|
319
|
+
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
|
320
|
+
print(f"margin listening on {inbox.cfg.listen}", flush=True)
|
|
321
|
+
last_poll = last_sync = 0.0
|
|
322
|
+
while True:
|
|
323
|
+
now = time.time()
|
|
324
|
+
try:
|
|
325
|
+
if now - last_sync >= sync_every and sync_pass:
|
|
326
|
+
sync_pass()
|
|
327
|
+
last_sync = now
|
|
328
|
+
if now - last_poll >= inbox.cfg.poll_seconds:
|
|
329
|
+
inbox.poll()
|
|
330
|
+
last_poll = now
|
|
331
|
+
for route in inbox.deliver(now):
|
|
332
|
+
print(f"delivered a batch to {route}", flush=True)
|
|
333
|
+
except Exception as e:
|
|
334
|
+
print(f"loop error: {e}", file=sys.stderr, flush=True)
|
|
335
|
+
time.sleep(5)
|