-
Notifications
You must be signed in to change notification settings - Fork 0
H2A: add pure feed status decision foundation #46
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
235 changes: 235 additions & 0 deletions
235
src/political_event_tracking_research/feed_status_decision.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,235 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import hashlib | ||
| import json | ||
| import re | ||
| from collections.abc import Iterable, Mapping | ||
| from dataclasses import dataclass | ||
| from enum import Enum | ||
| from types import MappingProxyType | ||
|
|
||
|
|
||
| STATUS_VERSION = "pert.feed_status_decision.v1" | ||
| MAX_SAFE_JSON_INTEGER = 2**53 - 1 | ||
| MAX_ROWS_PER_FEED = 10_000 | ||
| _ERROR_RE = re.compile(r"^[a-z][a-z0-9_]{0,63}$") | ||
| _DIGEST_RE = re.compile(r"^[0-9a-f]{64}$") | ||
| _ROW_KEYS = ("item_id", "published_at", "source_type", "source_url", "author", "text") | ||
| _OUTCOME_KEYS = frozenset({"feed_id", "feed_url", "kind", "state", "rows", "error_code"}) | ||
| _WIRE_KEYS = frozenset( | ||
| { | ||
| "status_version", | ||
| "configured_feed_count", | ||
| "feed_count", | ||
| "successful_feed_count", | ||
| "failed_feed_count", | ||
| "quarantined_feed_count", | ||
| "accepted_row_count", | ||
| "rejected_row_count", | ||
| "publication_complete", | ||
| "eligible_for_live_publication", | ||
| "aggregate_row_digest", | ||
| "feeds", | ||
| } | ||
| ) | ||
|
|
||
|
|
||
| class DecisionContractError(ValueError): | ||
| """Sanitized error for malformed already-validated primitive outcomes.""" | ||
|
|
||
| def __init__(self, code: str): | ||
| super().__init__(code) | ||
| self.code = code | ||
|
|
||
|
|
||
| class DecisionKind(str, Enum): | ||
| SUCCESS = "success" | ||
| QUARANTINE = "quarantine" | ||
| HARD_FAIL = "hard_fail" | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class _Row: | ||
| item_id: str | ||
| published_at: str | ||
| source_type: str | ||
| source_url: str | ||
| author: str | ||
| text: str | ||
|
|
||
| def wire(self) -> dict[str, str]: | ||
| return {key: getattr(self, key) for key in _ROW_KEYS} | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class StatusEvidence: | ||
| status: Mapping[str, object] | ||
| canonical_bytes: bytes | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class ProducerDecision: | ||
| kind: DecisionKind | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class StatusDecision: | ||
| evidence: StatusEvidence | ||
| decision: ProducerDecision | ||
|
|
||
|
|
||
| def _fail(code: str) -> None: | ||
| raise DecisionContractError(code) | ||
|
|
||
|
|
||
| def _mapping(value: object, keys: frozenset[str], code: str) -> dict[str, object]: | ||
| if not isinstance(value, Mapping): | ||
| _fail(code) | ||
| try: | ||
| data = dict(value) | ||
| except (AttributeError, KeyError, OverflowError, RuntimeError, TypeError, UnicodeError, ValueError): | ||
| _fail(code) | ||
| if set(data) != keys or any(type(key) is not str for key in data): | ||
| _fail(code) | ||
| return data | ||
|
|
||
|
|
||
| def _string(value: object, code: str, *, allow_empty: bool = False) -> str: | ||
| if type(value) is not str or (not allow_empty and not value) or any(ord(char) < 0x20 for char in value): | ||
| _fail(code) | ||
| return value | ||
|
|
||
|
|
||
| def _row(value: object) -> _Row: | ||
| data = _mapping(value, frozenset(_ROW_KEYS), "row_invalid") | ||
| values = [_string(data[key], "row_invalid", allow_empty=key == "author") for key in _ROW_KEYS] | ||
| return _Row(*values) | ||
|
|
||
|
|
||
| def _parse_outcome(value: object) -> tuple[dict[str, object], list[_Row]]: | ||
| data = _mapping(value, _OUTCOME_KEYS, "outcome_invalid") | ||
| feed_id = _string(data["feed_id"], "feed_invalid") | ||
| feed_url = _string(data["feed_url"], "feed_invalid") | ||
| kind = data["kind"] | ||
| state = data["state"] | ||
| if type(kind) is not str or kind not in {"rss2", "atom", "unknown"}: | ||
| _fail("feed_kind_invalid") | ||
| if type(state) is not str or state not in {"accepted", "failed", "quarantined"}: | ||
| _fail("feed_state_invalid") | ||
| rows_value = data["rows"] | ||
| if not isinstance(rows_value, (list, tuple)) or len(rows_value) > MAX_ROWS_PER_FEED: | ||
| _fail("rows_invalid") | ||
| rows = [_row(item) for item in rows_value] | ||
| error = data["error_code"] | ||
| if error is not None and (type(error) is not str or not _ERROR_RE.fullmatch(error)): | ||
| _fail("error_code_invalid") | ||
| if state in {"accepted", "quarantined"} and kind not in {"rss2", "atom"}: | ||
| _fail("feed_kind_invalid") | ||
| if state == "accepted" and (not rows or error is not None): | ||
| _fail("outcome_invariant_invalid") | ||
| if state == "quarantined" and (rows or error is None): | ||
| _fail("outcome_invariant_invalid") | ||
| if state == "failed" and (rows or error is None): | ||
| _fail("outcome_invariant_invalid") | ||
| return {"feed_id": feed_id, "feed_url": feed_url, "kind": kind, "state": state, "error_code": error}, rows | ||
|
|
||
|
|
||
| def _row_key(row: _Row) -> tuple[str, ...]: | ||
| return tuple(getattr(row, key) for key in _ROW_KEYS) | ||
|
|
||
|
|
||
| def _freeze(value: object) -> object: | ||
| if isinstance(value, dict): | ||
| return MappingProxyType({key: _freeze(child) for key, child in value.items()}) | ||
| if isinstance(value, list): | ||
| return tuple(_freeze(child) for child in value) | ||
| return value | ||
|
|
||
|
|
||
| def _digest(rows: list[_Row]) -> str: | ||
| ordered = sorted(rows, key=_row_key) | ||
| try: | ||
| data = json.dumps( | ||
| [row.wire() for row in ordered], | ||
| ensure_ascii=False, | ||
| sort_keys=True, | ||
| separators=(",", ":"), | ||
| allow_nan=False, | ||
| ).encode("utf-8") | ||
| except (RecursionError, TypeError, UnicodeError, ValueError): | ||
| _fail("digest_invalid") | ||
| return hashlib.sha256(data).hexdigest() | ||
|
|
||
|
|
||
| def _canonical(value: object) -> bytes: | ||
| try: | ||
| return json.dumps( | ||
| value, | ||
| ensure_ascii=False, | ||
| sort_keys=True, | ||
| separators=(",", ":"), | ||
| allow_nan=False, | ||
| ).encode("utf-8") | ||
| except (RecursionError, TypeError, UnicodeError, ValueError): | ||
| _fail("status_serialization_invalid") | ||
|
|
||
|
|
||
| def _build_status(parsed: list[tuple[dict[str, object], list[_Row]]]) -> dict[str, object]: | ||
| parsed.sort(key=lambda item: (item[0]["feed_id"], item[0]["feed_url"])) | ||
| accepted_rows = sorted( | ||
| [row for data, rows in parsed if data["state"] == "accepted" for row in rows], key=_row_key | ||
| ) | ||
| feeds = [] | ||
| for data, rows in parsed: | ||
| accepted = data["state"] == "accepted" | ||
| feeds.append( | ||
| { | ||
| "feed_id": data["feed_id"], | ||
| "feed_url": data["feed_url"], | ||
| "kind": data["kind"], | ||
| "state": data["state"], | ||
| "accepted_row_count": len(rows) if accepted else 0, | ||
| "rejected_row_count": 0, | ||
| "row_digest": _digest(rows) if accepted else hashlib.sha256(b"[]").hexdigest(), | ||
| "error_code": data["error_code"], | ||
| } | ||
| ) | ||
| failed = sum(data["state"] == "failed" for data, _ in parsed) | ||
| quarantined = sum(data["state"] == "quarantined" for data, _ in parsed) | ||
| complete = failed == 0 and quarantined == 0 | ||
| return { | ||
| "status_version": STATUS_VERSION, | ||
| "configured_feed_count": len(parsed), | ||
| "feed_count": len(parsed), | ||
| "successful_feed_count": len(parsed) - failed - quarantined, | ||
| "failed_feed_count": failed, | ||
| "quarantined_feed_count": quarantined, | ||
| "accepted_row_count": len(accepted_rows), | ||
| "rejected_row_count": 0, | ||
| "publication_complete": complete, | ||
| "eligible_for_live_publication": complete, | ||
| "aggregate_row_digest": _digest(accepted_rows), | ||
| "feeds": feeds, | ||
| } | ||
|
|
||
|
|
||
| def build_status_decision(outcomes: Iterable[Mapping[str, object]]) -> StatusDecision: | ||
| try: | ||
| values = list(outcomes) | ||
| except (AttributeError, RuntimeError, TypeError, ValueError): | ||
| _fail("outcomes_invalid") | ||
| if not values: | ||
| _fail("feed_config_empty") | ||
| parsed = [_parse_outcome(value) for value in values] | ||
| ids = [data["feed_id"] for data, _ in parsed] | ||
| if len(set(ids)) != len(ids): | ||
| _fail("feed_duplicate") | ||
| status = _build_status(parsed) | ||
| evidence = StatusEvidence(_freeze(status), _canonical(status)) | ||
| if status["failed_feed_count"]: | ||
| kind = DecisionKind.HARD_FAIL | ||
| elif status["quarantined_feed_count"]: | ||
| kind = DecisionKind.QUARANTINE | ||
| else: | ||
| kind = DecisionKind.SUCCESS | ||
| return StatusDecision(evidence, ProducerDecision(kind)) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import json | ||
|
|
||
| import pytest | ||
|
|
||
| from political_event_tracking_research.feed_status_decision import ( | ||
| DecisionContractError, | ||
| DecisionKind, | ||
| StatusDecision, | ||
| build_status_decision, | ||
| ) | ||
|
|
||
|
|
||
| ROW = { | ||
| "item_id": "a-1", | ||
| "published_at": "2026-05-01T12:30:00Z", | ||
| "source_type": "official", | ||
| "source_url": "https://example.test/a", | ||
| "author": "", | ||
| "text": "event", | ||
| } | ||
|
|
||
|
|
||
| def outcome( | ||
| feed_id: str, | ||
| state: str, | ||
| *, | ||
| kind: str = "rss2", | ||
| rows: list[dict[str, str]] | None = None, | ||
| error_code: str | None = None, | ||
| ) -> dict[str, object]: | ||
| return { | ||
| "feed_id": feed_id, | ||
| "feed_url": f"https://example.test/{feed_id}", | ||
| "kind": kind, | ||
| "state": state, | ||
| "rows": rows if rows is not None else ([ROW] if state == "accepted" else []), | ||
| "error_code": error_code, | ||
| } | ||
|
|
||
|
|
||
| def test_all_quarantined_returns_evidence_and_quarantine_decision() -> None: | ||
| result = build_status_decision([outcome("empty", "quarantined", error_code="zero_entries")]) | ||
| assert isinstance(result, StatusDecision) | ||
| assert result.decision.kind is DecisionKind.QUARANTINE | ||
| assert result.evidence.status["publication_complete"] is False | ||
| assert result.evidence.status["eligible_for_live_publication"] is False | ||
| assert result.evidence.status["accepted_row_count"] == 0 | ||
|
|
||
|
|
||
| def test_failed_and_mixed_failed_return_evidence_without_raising() -> None: | ||
| for records in ( | ||
| [outcome("bad", "failed", kind="unknown", error_code="fetch_failed")], | ||
| [ | ||
| outcome("bad", "failed", kind="unknown", error_code="fetch_failed"), | ||
| outcome("empty", "quarantined", error_code="zero_entries"), | ||
| ], | ||
| [outcome("good", "accepted"), outcome("bad", "failed", kind="unknown", error_code="fetch_failed")], | ||
| ): | ||
| result = build_status_decision(records) | ||
| assert result.decision.kind is DecisionKind.HARD_FAIL | ||
| assert result.evidence.status["failed_feed_count"] == 1 | ||
| assert result.evidence.canonical_bytes == result.evidence.canonical_bytes | ||
|
|
||
|
|
||
| def test_accepted_and_quarantined_returns_incomplete_quarantine_decision() -> None: | ||
| result = build_status_decision( | ||
| [outcome("good", "accepted"), outcome("empty", "quarantined", error_code="zero_entries")] | ||
| ) | ||
| assert result.decision.kind is DecisionKind.QUARANTINE | ||
| assert result.evidence.status["accepted_row_count"] == 1 | ||
| assert result.evidence.status["publication_complete"] is False | ||
|
|
||
|
|
||
| def test_all_accepted_returns_success() -> None: | ||
| result = build_status_decision([outcome("a", "accepted"), outcome("b", "accepted")]) | ||
| assert result.decision.kind is DecisionKind.SUCCESS | ||
| assert result.evidence.status["publication_complete"] is True | ||
| assert result.evidence.status["eligible_for_live_publication"] is True | ||
|
|
||
|
|
||
| def test_empty_or_malformed_input_is_sanitized_contract_error() -> None: | ||
| with pytest.raises(DecisionContractError, match="feed_config_empty"): | ||
| build_status_decision([]) | ||
| with pytest.raises(DecisionContractError, match="feed_kind_invalid"): | ||
| build_status_decision([outcome("bad", "accepted", kind="unknown")]) | ||
|
|
||
|
|
||
| def test_base_exception_propagates() -> None: | ||
| def outcomes(): | ||
| raise KeyboardInterrupt | ||
| yield outcome("never", "accepted") | ||
|
|
||
| with pytest.raises(KeyboardInterrupt): | ||
| build_status_decision(outcomes()) | ||
|
|
||
|
|
||
| def test_wire_is_canonical_and_deterministic() -> None: | ||
| first = build_status_decision([outcome("b", "accepted"), outcome("a", "accepted")]) | ||
| second = build_status_decision([outcome("a", "accepted"), outcome("b", "accepted")]) | ||
| assert first.evidence.canonical_bytes == second.evidence.canonical_bytes | ||
| assert json.loads(first.evidence.canonical_bytes)["feeds"][0]["feed_id"] == "a" | ||
|
|
||
|
|
||
| def test_status_evidence_is_deeply_immutable_and_bound_to_bytes() -> None: | ||
| result = build_status_decision([outcome("a", "accepted")]) | ||
| with pytest.raises(TypeError): | ||
| result.evidence.status["feed_count"] = 99 | ||
| with pytest.raises(TypeError): | ||
| result.evidence.status["feeds"][0]["feed_id"] = "tampered" | ||
| assert json.loads(result.evidence.canonical_bytes)["feed_count"] == result.evidence.status["feed_count"] | ||
|
|
||
|
|
||
| def test_digest_sort_is_total_for_equal_published_at_and_item_id() -> None: | ||
| first = {**ROW, "text": "first"} | ||
| second = {**ROW, "text": "second"} | ||
| left = build_status_decision([outcome("a", "accepted", rows=[first, second])]) | ||
| right = build_status_decision([outcome("a", "accepted", rows=[second, first])]) | ||
| assert left.evidence.canonical_bytes == right.evidence.canonical_bytes |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Accepted rows are accepted after only
_stringchecks, so an outcome withpublished_atlikenot-a-datestill returnsDecisionKind.SUCCESSandeligible_for_live_publication=True. The existing source-items consumer derives event dates from this field and callsparse_date, so a malformed timestamp can be marked live-eligible here and then fail the pipeline when a matching item is extracted; validate the ISO/Z timestamp before counting the row accepted.Useful? React with 👍 / 👎.