Skip to content

Commit 76fa647

Browse files
Pigbibicodex
andcommitted
Add pure RSS fetch acceptance contract
Co-Authored-By: Codex <noreply@openai.com>
1 parent cf0dcaf commit 76fa647

2 files changed

Lines changed: 293 additions & 0 deletions

File tree

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
"""Pure RSS/Atom content acceptance and canonical fetch-status contract."""
2+
from __future__ import annotations
3+
4+
import email.utils
5+
import json
6+
import re
7+
import xml.etree.ElementTree as ET
8+
from collections.abc import Iterable, Mapping
9+
from datetime import datetime, timezone
10+
11+
_STATUS_KEYS = frozenset(
12+
{
13+
"status_version",
14+
"configured_feed_count",
15+
"feed_count",
16+
"successful_feed_count",
17+
"failed_feed_count",
18+
"stale_feed_count",
19+
"missing_feed_count",
20+
"quarantined_feed_count",
21+
"accepted_row_count",
22+
"rejected_row_count",
23+
"publication_complete",
24+
"eligible_for_live_publication",
25+
"zero_entry_policy",
26+
"feeds",
27+
}
28+
)
29+
_FEED_KEYS = frozenset({"feed_id", "feed_url", "kind", "accepted_row_count", "rejected_row_count", "state", "error_code"})
30+
_STATES = frozenset({"accepted", "failed", "stale", "missing", "quarantined"})
31+
_KINDS = frozenset({"rss", "atom", "unknown"})
32+
_SAFE_ERROR = re.compile(r"^[a-z][a-z0-9_]*$")
33+
_ATOM = "http://www.w3.org/2005/Atom"
34+
35+
36+
class FetchAcceptanceError(ValueError):
37+
def __init__(self, code: str) -> None:
38+
self.code = code
39+
super().__init__(code)
40+
41+
42+
def _fail(code: str) -> FetchAcceptanceError:
43+
return FetchAcceptanceError(code)
44+
45+
46+
def _text(element: ET.Element | None, names: tuple[str, ...]) -> str:
47+
if element is None:
48+
return ""
49+
for name in names:
50+
child = element.find(name)
51+
if child is not None and child.text:
52+
return child.text.strip()
53+
return ""
54+
55+
56+
def _atom_link(entry: ET.Element) -> str:
57+
for child in entry:
58+
if child.tag == f"{{{_ATOM}}}link" and child.attrib.get("href"):
59+
return child.attrib["href"]
60+
return _text(entry, (f"{{{_ATOM}}}id", "id"))
61+
62+
63+
def _valid_timestamp(value: str) -> bool:
64+
if not value:
65+
return False
66+
try:
67+
parsed = email.utils.parsedate_to_datetime(value)
68+
except (TypeError, ValueError):
69+
try:
70+
normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
71+
parsed = datetime.fromisoformat(normalized)
72+
except (TypeError, ValueError):
73+
return False
74+
return parsed.tzinfo is not None and parsed.astimezone(timezone.utc) is not None
75+
76+
77+
def _feed_result(feed_id: str, feed_url: str, kind: str, accepted: int, rejected: int, state: str, error_code: str | None) -> dict[str, object]:
78+
if type(feed_id) is not str or not feed_id or type(feed_url) is not str or not feed_url or kind not in _KINDS or state not in _STATES or type(accepted) is not int or accepted < 0 or type(rejected) is not int or rejected < 0 or type(error_code) not in {str, type(None)} or (error_code is not None and not _SAFE_ERROR.fullmatch(error_code)):
79+
raise _fail("feed_result_invalid")
80+
return {"feed_id": feed_id, "feed_url": feed_url, "kind": kind, "accepted_row_count": accepted, "rejected_row_count": rejected, "state": state, "error_code": error_code}
81+
82+
83+
def classify_feed_payload(feed_id: str, feed_url: str, payload: bytes) -> dict[str, object]:
84+
if type(payload) is not bytes:
85+
raise _fail("feed_payload_invalid")
86+
try:
87+
root = ET.fromstring(payload)
88+
except (ET.ParseError, UnicodeError, ValueError):
89+
return _feed_result(feed_id, feed_url, "unknown", 0, 0, "failed", "xml_invalid")
90+
if root.tag == "rss":
91+
if root.attrib.get("version") not in {"2.0"}:
92+
return _feed_result(feed_id, feed_url, "unknown", 0, 0, "failed", "rss_schema_unsupported")
93+
channel = root.find("./channel")
94+
if channel is None:
95+
return _feed_result(feed_id, feed_url, "rss", 0, 0, "failed", "rss_schema_invalid")
96+
entries = channel.findall("./item")
97+
kind = "rss"
98+
def row_valid(entry: ET.Element) -> bool:
99+
return bool(_text(entry, ("title",)) and (_text(entry, ("link",)) or _text(entry, ("guid",))) and _valid_timestamp(_text(entry, ("pubDate", "{http://purl.org/dc/elements/1.1/}date"))))
100+
elif root.tag == f"{{{_ATOM}}}feed":
101+
entries = root.findall(f"{{{_ATOM}}}entry")
102+
kind = "atom"
103+
def row_valid(entry: ET.Element) -> bool:
104+
published = _text(entry, (f"{{{_ATOM}}}published", f"{{{_ATOM}}}updated"))
105+
return bool(_text(entry, (f"{{{_ATOM}}}title",)) and _atom_link(entry) and _valid_timestamp(published))
106+
else:
107+
return _feed_result(feed_id, feed_url, "unknown", 0, 0, "failed", "payload_not_rss_atom")
108+
accepted = sum(row_valid(entry) for entry in entries)
109+
rejected = len(entries) - accepted
110+
if not entries:
111+
return _feed_result(feed_id, feed_url, kind, 0, 0, "quarantined", "zero_entries")
112+
if rejected:
113+
return _feed_result(feed_id, feed_url, kind, accepted, rejected, "quarantined", "entry_invalid")
114+
return _feed_result(feed_id, feed_url, kind, accepted, 0, "accepted", None)
115+
116+
117+
def _validate_feed_result(value: object) -> dict[str, object]:
118+
if not isinstance(value, Mapping) or set(value) != _FEED_KEYS:
119+
raise _fail("feed_result_shape_invalid")
120+
result = _feed_result(value["feed_id"], value["feed_url"], value["kind"], value["accepted_row_count"], value["rejected_row_count"], value["state"], value["error_code"])
121+
if result != dict(value):
122+
raise _fail("feed_result_noncanonical")
123+
if result["state"] == "accepted" and (result["accepted_row_count"] <= 0 or result["rejected_row_count"] != 0 or result["error_code"] is not None):
124+
raise _fail("feed_result_invalid")
125+
if result["state"] == "quarantined" and not result["error_code"]:
126+
raise _fail("feed_result_invalid")
127+
return result
128+
129+
130+
def build_acceptance_status(feed_results: Iterable[Mapping[str, object]]) -> dict[str, object]:
131+
if not isinstance(feed_results, list) or not feed_results:
132+
raise _fail("configured_feed_empty")
133+
feeds = [_validate_feed_result(item) for item in feed_results]
134+
if len({item["feed_id"] for item in feeds}) != len(feeds):
135+
raise _fail("feed_duplicate")
136+
feeds.sort(key=lambda item: (item["feed_id"], item["feed_url"]))
137+
accepted = sum(item["state"] == "accepted" for item in feeds)
138+
failed = sum(item["state"] == "failed" for item in feeds)
139+
stale = sum(item["state"] == "stale" for item in feeds)
140+
missing = sum(item["state"] == "missing" for item in feeds)
141+
quarantined = sum(item["state"] == "quarantined" for item in feeds)
142+
accepted_rows = sum(item["accepted_row_count"] for item in feeds)
143+
rejected_rows = sum(item["rejected_row_count"] for item in feeds)
144+
complete = len(feeds) > 0 and accepted == len(feeds) and not any((failed, stale, missing, quarantined)) and accepted_rows > 0
145+
return {
146+
"status_version": "pert.fetch_acceptance.v1",
147+
"configured_feed_count": len(feeds),
148+
"feed_count": len(feeds),
149+
"successful_feed_count": accepted,
150+
"failed_feed_count": failed,
151+
"stale_feed_count": stale,
152+
"missing_feed_count": missing,
153+
"quarantined_feed_count": quarantined,
154+
"accepted_row_count": accepted_rows,
155+
"rejected_row_count": rejected_rows,
156+
"publication_complete": complete,
157+
"eligible_for_live_publication": complete,
158+
"zero_entry_policy": "quarantine",
159+
"feeds": feeds,
160+
}
161+
162+
163+
def _validate_status(value: object) -> dict[str, object]:
164+
if not isinstance(value, Mapping) or set(value) != _STATUS_KEYS or value.get("status_version") != "pert.fetch_acceptance.v1" or value.get("zero_entry_policy") != "quarantine":
165+
raise _fail("status_shape_invalid")
166+
integer_keys = ("configured_feed_count", "feed_count", "successful_feed_count", "failed_feed_count", "stale_feed_count", "missing_feed_count", "quarantined_feed_count", "accepted_row_count", "rejected_row_count")
167+
if any(type(value[key]) is not int or value[key] < 0 for key in integer_keys) or type(value["publication_complete"]) is not bool or type(value["eligible_for_live_publication"]) is not bool or value["publication_complete"] != value["eligible_for_live_publication"] or not isinstance(value["feeds"], list):
168+
raise _fail("status_counter_invalid")
169+
expected = build_acceptance_status(value["feeds"])
170+
if dict(value) != expected:
171+
raise _fail("status_counter_mismatch")
172+
return expected
173+
174+
175+
def serialize_status(value: Mapping[str, object]) -> bytes:
176+
try:
177+
payload = _validate_status(value)
178+
return json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")
179+
except FetchAcceptanceError:
180+
raise
181+
except (TypeError, ValueError, UnicodeError, OverflowError, RecursionError):
182+
raise _fail("status_serialization_invalid") from None
183+
184+
185+
def parse_status_bytes(wire: bytes) -> dict[str, object]:
186+
if type(wire) is not bytes:
187+
raise _fail("status_wire_invalid")
188+
def pairs(items: list[tuple[str, object]]) -> dict[str, object]:
189+
result: dict[str, object] = {}
190+
for key, item in items:
191+
if key in result:
192+
raise _fail("status_duplicate_key")
193+
result[key] = item
194+
return result
195+
try:
196+
value = json.loads(wire.decode("utf-8"), object_pairs_hook=pairs)
197+
except FetchAcceptanceError:
198+
raise
199+
except (UnicodeError, json.JSONDecodeError, TypeError, ValueError, RecursionError):
200+
raise _fail("status_wire_invalid") from None
201+
parsed = _validate_status(value)
202+
if serialize_status(parsed) != wire:
203+
raise _fail("status_noncanonical")
204+
return parsed

tests/test_fetch_acceptance.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
from __future__ import annotations
2+
3+
import json
4+
5+
import pytest
6+
7+
from political_event_tracking_research.fetch_acceptance import (
8+
FetchAcceptanceError,
9+
build_acceptance_status,
10+
classify_feed_payload,
11+
parse_status_bytes,
12+
serialize_status,
13+
)
14+
15+
16+
RSS = b"""<?xml version=\"1.0\"?><rss version=\"2.0\"><channel><title>Feed</title><item><title>Event</title><link>https://example.test/event</link><pubDate>Fri, 01 May 2026 12:30:00 GMT</pubDate><description>Text</description></item></channel></rss>"""
17+
ATOM = b"""<?xml version=\"1.0\"?><feed xmlns=\"http://www.w3.org/2005/Atom\"><title>Feed</title><entry><title>Event</title><link href=\"https://example.test/event\"/><updated>2026-05-01T12:30:00Z</updated><summary>Text</summary></entry></feed>"""
18+
NON_FEED = b"<root><item><title>Not RSS</title></item></root>"
19+
20+
21+
def test_rss_and_atom_are_recognized_with_exact_rows() -> None:
22+
rss = classify_feed_payload("rss", "https://example.test/rss", RSS)
23+
atom = classify_feed_payload("atom", "https://example.test/atom", ATOM)
24+
assert (rss["kind"], rss["accepted_row_count"], rss["rejected_row_count"]) == ("rss", 1, 0)
25+
assert (atom["kind"], atom["accepted_row_count"], atom["rejected_row_count"]) == ("atom", 1, 0)
26+
27+
28+
@pytest.mark.parametrize("payload", [NON_FEED, b"<rss>", b"not xml"])
29+
def test_non_rss_or_malformed_payload_fails_closed(payload: bytes) -> None:
30+
result = classify_feed_payload("feed", "https://example.test/feed", payload)
31+
assert result["state"] == "failed"
32+
assert result["accepted_row_count"] == 0
33+
34+
35+
def test_unsupported_rss_schema_is_not_successful() -> None:
36+
result = classify_feed_payload("feed", "https://example.test/feed", RSS.replace(b'version="2.0"', b'version="1.0"'))
37+
assert result["state"] == "failed"
38+
assert result["error_code"] == "rss_schema_unsupported"
39+
40+
41+
def test_zero_entry_is_quarantined_not_complete() -> None:
42+
result = classify_feed_payload("empty", "https://example.test/empty", b"<rss version='2.0'><channel><title>Empty</title></channel></rss>")
43+
status = build_acceptance_status([result])
44+
assert result["state"] == "quarantined"
45+
assert status["publication_complete"] is False
46+
assert status["eligible_for_live_publication"] is False
47+
assert status["zero_entry_policy"] == "quarantine"
48+
49+
50+
def test_mixed_partial_counts_are_exact_and_incomplete() -> None:
51+
accepted = classify_feed_payload("ok", "https://example.test/ok", RSS)
52+
failed = classify_feed_payload("bad", "https://example.test/bad", NON_FEED)
53+
status = build_acceptance_status([accepted, failed])
54+
assert status["configured_feed_count"] == 2
55+
assert status["successful_feed_count"] == 1
56+
assert status["failed_feed_count"] == 1
57+
assert status["accepted_row_count"] == 1
58+
assert status["publication_complete"] is False
59+
60+
61+
def test_empty_config_and_counter_mismatch_fail_closed() -> None:
62+
with pytest.raises(FetchAcceptanceError):
63+
build_acceptance_status([])
64+
status = build_acceptance_status([classify_feed_payload("ok", "https://example.test/ok", RSS)])
65+
tampered = dict(status)
66+
tampered["accepted_row_count"] = 999
67+
with pytest.raises(FetchAcceptanceError):
68+
serialize_status(tampered)
69+
70+
71+
def test_status_canonical_roundtrip_and_tamper_rejection() -> None:
72+
status = build_acceptance_status([classify_feed_payload("ok", "https://example.test/ok", RSS)])
73+
wire = serialize_status(status)
74+
assert parse_status_bytes(wire) == status
75+
assert serialize_status(parse_status_bytes(wire)) == wire
76+
with pytest.raises(FetchAcceptanceError):
77+
parse_status_bytes(b" " + wire)
78+
payload = json.loads(wire)
79+
payload["unknown"] = True
80+
with pytest.raises(FetchAcceptanceError):
81+
serialize_status(payload)
82+
83+
84+
def test_invalid_entry_is_counted_not_accepted() -> None:
85+
payload = RSS.replace(b"<link>https://example.test/event</link>", b"")
86+
result = classify_feed_payload("feed", "https://example.test/feed", payload)
87+
assert result["accepted_row_count"] == 0
88+
assert result["rejected_row_count"] == 1
89+
assert result["state"] == "quarantined"

0 commit comments

Comments
 (0)