feat: add deterministic Anwarpy publisher
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
import fcntl
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from page_state import find_marker
|
||||
from publish_one import (
|
||||
AmbiguousWrite,
|
||||
DeterministicFailure,
|
||||
PublicVerificationError,
|
||||
Publisher,
|
||||
)
|
||||
|
||||
|
||||
WORKSPACE = "workspace"
|
||||
PENDING_OLD = "anwarpy: status=pending | access=free | category=نماذج | queued_at=2026-10-08T09:00:00+03:00"
|
||||
PENDING_NEW = "anwarpy: status=pending | access=vip | category=أدوات | queued_at=2026-10-09T09:00:00+03:00"
|
||||
BLOCKED_OLDER = "anwarpy: status=blocked | access=free | category=نماذج | queued_at=2026-10-07T09:00:00+03:00 | blocked_reason=cover"
|
||||
REPUBLISH = "anwarpy: status=republish | access=vip | category=أدوات | queued_at=2026-10-10T09:00:00+03:00 | slug=existing-guide | published_at=2026-10-09T12:00:00+03:00 | url=https://anwarpy.ai/posts/existing-guide"
|
||||
|
||||
|
||||
def blocks(marker, evidence="Public source text"):
|
||||
return [
|
||||
{"id": "body", "type": "paragraph", "text": evidence, "depth": 0},
|
||||
{"id": "marker", "type": "paragraph", "text": marker, "depth": 0},
|
||||
]
|
||||
|
||||
|
||||
class FakeAppFlowy:
|
||||
def __init__(self, page_markers):
|
||||
self.pages = [
|
||||
{"view_id": page_id, "name": title}
|
||||
for page_id, (title, _marker) in page_markers.items()
|
||||
]
|
||||
self.block_map = {
|
||||
page_id: blocks(marker) for page_id, (_title, marker) in page_markers.items()
|
||||
}
|
||||
self.list_calls = 0
|
||||
self.edits = []
|
||||
|
||||
def list_pages(self):
|
||||
self.list_calls += 1
|
||||
return self.pages
|
||||
|
||||
def get_blocks(self, page_id):
|
||||
return [dict(block) for block in self.block_map[page_id]]
|
||||
|
||||
def edit_block(self, page_id, block_id, text):
|
||||
self.edits.append((page_id, block_id, text))
|
||||
for block in self.block_map[page_id]:
|
||||
if block["id"] == block_id:
|
||||
block["text"] = text
|
||||
return
|
||||
raise KeyError(block_id)
|
||||
|
||||
def marker(self, page_id):
|
||||
return find_marker(self.get_blocks(page_id)).meta
|
||||
|
||||
|
||||
class FakeGenerator:
|
||||
def __init__(self, slugs=None):
|
||||
self.slugs = slugs or {}
|
||||
self.calls = []
|
||||
|
||||
def generate(self, title, evidence, meta):
|
||||
self.calls.append((title, evidence, dict(meta)))
|
||||
slug = self.slugs.get(title, "generated-guide")
|
||||
return {
|
||||
"title": f"دليل {title}",
|
||||
"slug": slug,
|
||||
"dek": "شرح عربي عملي ومباشر",
|
||||
"teaser": "مقدمة مجانية مفيدة" if meta["access"] == "vip" else "",
|
||||
"sections": [
|
||||
{"heading": "المشكلة", "paragraphs": ["شرح واضح للمشكلة والحل."], "code": None},
|
||||
{"heading": "التحقق", "paragraphs": ["تحقق من النتيجة بخطوة قابلة للقياس."], "code": None},
|
||||
{"heading": "القيود", "paragraphs": ["راجع القيود قبل الاستخدام."], "code": None},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class FakeMedia:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def render(self, article, meta):
|
||||
self.calls.append((article["slug"], dict(meta)))
|
||||
return {
|
||||
"coverImage": {"filename": f"{article['slug']}.jpg", "dataBase64": "/9j/2Q=="},
|
||||
"video_path": f"/tmp/{article['slug']}.mp4",
|
||||
}
|
||||
|
||||
|
||||
class FakeApi:
|
||||
def __init__(self, records=None, timeout_after_create=False, public_ok=True):
|
||||
self.records = records or {}
|
||||
self.timeout_after_create = timeout_after_create
|
||||
self.public_ok = public_ok
|
||||
self.create_calls = []
|
||||
self.update_calls = []
|
||||
self.verify_calls = []
|
||||
|
||||
def lookup(self, slug):
|
||||
record = self.records.get(slug)
|
||||
return dict(record) if record else None
|
||||
|
||||
def create(self, body):
|
||||
self.create_calls.append(dict(body))
|
||||
record = {
|
||||
"id": len(self.records) + 1,
|
||||
"title": body["title"],
|
||||
"slug": body["slug"],
|
||||
"status": "published" if body["publish"] else "draft",
|
||||
"access": body["access"],
|
||||
"category": body["category"],
|
||||
"publishedAt": "2026-10-09T12:20:11+00:00",
|
||||
"url": f"https://anwarpy.ai/posts/{body['slug']}",
|
||||
}
|
||||
self.records[body["slug"]] = record
|
||||
if self.timeout_after_create:
|
||||
self.timeout_after_create = False
|
||||
raise AmbiguousWrite("timeout after create")
|
||||
return dict(record)
|
||||
|
||||
def update(self, slug, body):
|
||||
self.update_calls.append((slug, dict(body)))
|
||||
old = self.records[slug]
|
||||
old.update({
|
||||
"title": body.get("title", old.get("title")),
|
||||
"status": "published" if body.get("publish") else old["status"],
|
||||
"access": body.get("access", old["access"]),
|
||||
"category": body.get("category", old["category"]),
|
||||
"publishedAt": old.get("publishedAt") or "2026-10-09T12:20:11+00:00",
|
||||
"url": f"https://anwarpy.ai/posts/{slug}",
|
||||
})
|
||||
return dict(old)
|
||||
|
||||
def verify_public(self, record):
|
||||
self.verify_calls.append(dict(record))
|
||||
return self.public_ok
|
||||
|
||||
|
||||
class PublisherTests(unittest.TestCase):
|
||||
def make_publisher(self, app, api, generator=None, media=None, lock_path=None):
|
||||
return Publisher(
|
||||
appflowy=app,
|
||||
api=api,
|
||||
generator=generator or FakeGenerator(),
|
||||
media=media or FakeMedia(),
|
||||
lock_path=lock_path or Path(tempfile.mkdtemp()) / "publisher.lock",
|
||||
)
|
||||
|
||||
def test_lock_loser_makes_no_external_calls(self):
|
||||
lock_path = Path(tempfile.mkdtemp()) / "publisher.lock"
|
||||
app = FakeAppFlowy({"page": ("Topic", PENDING_OLD)})
|
||||
api = FakeApi()
|
||||
with lock_path.open("a+") as handle:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
result = self.make_publisher(app, api, lock_path=lock_path).run()
|
||||
self.assertEqual(result, "busy")
|
||||
self.assertEqual(app.list_calls, 0)
|
||||
|
||||
def test_fifo_uses_queued_at_and_skips_blocked(self):
|
||||
app = FakeAppFlowy({
|
||||
"blocked": ("Blocked", BLOCKED_OLDER),
|
||||
"new": ("New", PENDING_NEW),
|
||||
"old": ("Old", PENDING_OLD),
|
||||
})
|
||||
generator = FakeGenerator({"Old": "old-guide", "New": "new-guide"})
|
||||
result = self.make_publisher(app, FakeApi(), generator=generator).run()
|
||||
self.assertEqual(result, "published:old-guide")
|
||||
self.assertEqual(generator.calls[0][0], "Old")
|
||||
self.assertEqual(app.marker("blocked")["status"], "blocked")
|
||||
self.assertEqual(app.marker("new")["status"], "pending")
|
||||
|
||||
def test_pending_absent_slug_posts_once_and_rerun_is_idle(self):
|
||||
app = FakeAppFlowy({"page": ("Topic", PENDING_OLD)})
|
||||
api = FakeApi()
|
||||
publisher = self.make_publisher(app, api)
|
||||
self.assertEqual(publisher.run(), "published:generated-guide")
|
||||
self.assertEqual(len(api.create_calls), 1)
|
||||
self.assertEqual(app.marker("page")["status"], "published")
|
||||
self.assertEqual(publisher.run(), "idle")
|
||||
self.assertEqual(len(api.create_calls), 1)
|
||||
|
||||
def test_existing_draft_is_promoted_without_post(self):
|
||||
record = {
|
||||
"id": 1, "title": "Draft", "slug": "generated-guide", "status": "draft",
|
||||
"access": "free", "category": "نماذج", "publishedAt": None,
|
||||
}
|
||||
app = FakeAppFlowy({"page": ("Topic", PENDING_OLD)})
|
||||
api = FakeApi({"generated-guide": record})
|
||||
self.assertEqual(self.make_publisher(app, api).run(), "published:generated-guide")
|
||||
self.assertEqual(api.create_calls, [])
|
||||
self.assertEqual(len(api.update_calls), 1)
|
||||
|
||||
def test_republish_puts_existing_slug_and_never_posts(self):
|
||||
record = {
|
||||
"id": 7, "title": "Old", "slug": "existing-guide", "status": "published",
|
||||
"access": "vip", "category": "أدوات", "publishedAt": "2026-10-09T09:00:00Z",
|
||||
"url": "https://anwarpy.ai/posts/existing-guide",
|
||||
}
|
||||
app = FakeAppFlowy({"page": ("Topic", REPUBLISH)})
|
||||
api = FakeApi({"existing-guide": record})
|
||||
generator = FakeGenerator({"Topic": "existing-guide"})
|
||||
self.assertEqual(self.make_publisher(app, api, generator=generator).run(), "published:existing-guide")
|
||||
self.assertEqual(api.create_calls, [])
|
||||
self.assertEqual(len(api.update_calls), 1)
|
||||
|
||||
def test_ambiguous_create_reconciles_without_second_post(self):
|
||||
app = FakeAppFlowy({"page": ("Topic", PENDING_OLD)})
|
||||
api = FakeApi(timeout_after_create=True)
|
||||
self.assertEqual(self.make_publisher(app, api).run(), "published:generated-guide")
|
||||
self.assertEqual(len(api.create_calls), 1)
|
||||
|
||||
def test_public_failure_leaves_publishing_and_does_not_repost(self):
|
||||
app = FakeAppFlowy({"page": ("Topic", PENDING_OLD)})
|
||||
api = FakeApi(public_ok=False)
|
||||
publisher = self.make_publisher(app, api)
|
||||
with self.assertRaises(PublicVerificationError):
|
||||
publisher.run()
|
||||
self.assertEqual(app.marker("page")["status"], "publishing")
|
||||
self.assertEqual(len(api.create_calls), 1)
|
||||
with self.assertRaises(PublicVerificationError):
|
||||
publisher.run()
|
||||
self.assertEqual(len(api.create_calls), 1)
|
||||
|
||||
def test_api_metadata_mismatch_leaves_publishing(self):
|
||||
app = FakeAppFlowy({"page": ("Topic", PENDING_OLD)})
|
||||
api = FakeApi()
|
||||
original_create = api.create
|
||||
|
||||
def mismatched(body):
|
||||
result = original_create(body)
|
||||
result["access"] = "vip"
|
||||
api.records[body["slug"]]["access"] = "vip"
|
||||
return result
|
||||
|
||||
api.create = mismatched
|
||||
with self.assertRaisesRegex(ValueError, "access mismatch"):
|
||||
self.make_publisher(app, api).run()
|
||||
self.assertEqual(app.marker("page")["status"], "publishing")
|
||||
self.assertEqual(len(api.create_calls), 1)
|
||||
|
||||
def test_generation_failure_blocks_page_without_api_write(self):
|
||||
app = FakeAppFlowy({"page": ("Topic", PENDING_OLD)})
|
||||
api = FakeApi()
|
||||
generator = FakeGenerator()
|
||||
|
||||
def fail(*_args):
|
||||
raise DeterministicFailure("generation", "model failed")
|
||||
|
||||
generator.generate = fail
|
||||
self.assertEqual(self.make_publisher(app, api, generator=generator).run(), "blocked:generation")
|
||||
self.assertEqual(app.marker("page")["status"], "blocked")
|
||||
self.assertEqual(api.create_calls, [])
|
||||
|
||||
def test_malformed_marker_is_reported_and_valid_page_can_run(self):
|
||||
app = FakeAppFlowy({
|
||||
"bad": ("Bad", "Anwarpy: pending"),
|
||||
"good": ("Good", PENDING_OLD),
|
||||
})
|
||||
publisher = self.make_publisher(app, FakeApi(), generator=FakeGenerator({"Good": "good-guide"}))
|
||||
self.assertEqual(publisher.run(), "published:good-guide")
|
||||
self.assertEqual(len(publisher.marker_errors), 1)
|
||||
self.assertIn("bad", publisher.marker_errors[0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user