-
Notifications
You must be signed in to change notification settings - Fork 0
H1: add validated feed primitive status #41
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
334 changes: 334 additions & 0 deletions
334
src/political_event_tracking_research/feed_primitives.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,334 @@ | ||
| """Validated producer rows and canonical per-feed status contract.""" | ||
| from __future__ import annotations | ||
|
|
||
| import hashlib | ||
| import json | ||
| import re | ||
| from collections.abc import Iterable, Mapping | ||
| from dataclasses import dataclass | ||
| from typing import Any | ||
|
|
||
| STATUS_VERSION = "pert.feed_primitives.v1" | ||
| MAX_ROWS_PER_FEED = 10_000 | ||
| MAX_SAFE_JSON_INTEGER = 2**53 - 1 | ||
| _ROW_KEYS = frozenset({"item_id", "published_at", "source_type", "source_url", "author", "text"}) | ||
| _FEED_KEYS = frozenset({"feed_id", "feed_url", "kind", "state", "rows", "error_code"}) | ||
| _FEED_WIRE_KEYS = frozenset( | ||
| {"feed_id", "feed_url", "kind", "state", "accepted_row_count", "rejected_row_count", "row_digest", "error_code"} | ||
| ) | ||
| _STATUS_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", | ||
| } | ||
| ) | ||
| _STATES = frozenset({"accepted", "failed", "quarantined"}) | ||
| _KINDS = frozenset({"rss", "atom", "unknown"}) | ||
| _SAFE_ERROR = re.compile(r"^[a-z][a-z0-9_]*$") | ||
|
|
||
|
|
||
| class PrimitiveStatusError(ValueError): | ||
| def __init__(self, code: str) -> None: | ||
| self.code = code | ||
| super().__init__(code) | ||
|
|
||
|
|
||
| def _fail(code: str) -> PrimitiveStatusError: | ||
| return PrimitiveStatusError(code) | ||
|
|
||
|
|
||
| def _canonical_bytes(value: object) -> bytes: | ||
| try: | ||
| return json.dumps( | ||
| value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False | ||
| ).encode("utf-8") | ||
| except (TypeError, ValueError, UnicodeError, OverflowError, RecursionError): | ||
| raise _fail("status_serialization_invalid") from None | ||
|
|
||
|
|
||
| def _digest(value: object) -> str: | ||
| return hashlib.sha256(_canonical_bytes(value)).hexdigest() | ||
|
|
||
|
|
||
| def _string(value: object, code: str, *, allow_empty: bool = True) -> str: | ||
| if type(value) is not str or (not allow_empty and not value): | ||
| raise _fail(code) | ||
| return value | ||
|
|
||
|
|
||
| def _safe_int(value: object, code: str) -> int: | ||
| if type(value) is not int or value < 0 or value > MAX_SAFE_JSON_INTEGER: | ||
| raise _fail(code) | ||
| return value | ||
|
|
||
|
|
||
| @dataclass(frozen=True, slots=True) | ||
| class PrimitiveRow: | ||
| item_id: str | ||
| published_at: str | ||
| source_type: str | ||
| source_url: str | ||
| author: str | ||
| text: str | ||
|
|
||
| @classmethod | ||
| def from_mapping(cls, value: object) -> "PrimitiveRow": | ||
| if isinstance(value, cls): | ||
| value = value.to_mapping() | ||
| if not isinstance(value, Mapping) or set(value) != _ROW_KEYS: | ||
| raise _fail("row_shape_invalid") | ||
| try: | ||
| snapshot = dict(value) | ||
| except (TypeError, ValueError, RuntimeError): | ||
| raise _fail("row_shape_invalid") from None | ||
| return cls( | ||
| item_id=_string(snapshot["item_id"], "row_invalid", allow_empty=False), | ||
| published_at=_string(snapshot["published_at"], "row_invalid", allow_empty=False), | ||
| source_type=_string(snapshot["source_type"], "row_invalid", allow_empty=False), | ||
| source_url=_string(snapshot["source_url"], "row_invalid", allow_empty=False), | ||
| author=_string(snapshot["author"], "row_invalid"), | ||
| text=_string(snapshot["text"], "row_invalid"), | ||
| ) | ||
|
|
||
| def to_mapping(self) -> dict[str, str]: | ||
| return { | ||
| "item_id": self.item_id, | ||
| "published_at": self.published_at, | ||
| "source_type": self.source_type, | ||
| "source_url": self.source_url, | ||
| "author": self.author, | ||
| "text": self.text, | ||
| } | ||
|
|
||
|
|
||
| def _snapshot_rows(value: object) -> tuple[PrimitiveRow, ...]: | ||
| if isinstance(value, (str, bytes, Mapping)) or not isinstance(value, Iterable): | ||
| raise _fail("rows_shape_invalid") | ||
| rows: list[PrimitiveRow] = [] | ||
| try: | ||
| for item in value: | ||
| if len(rows) >= MAX_ROWS_PER_FEED: | ||
| raise _fail("rows_limit_exceeded") | ||
| rows.append(PrimitiveRow.from_mapping(item)) | ||
| except PrimitiveStatusError: | ||
| raise | ||
| except (TypeError, ValueError, RuntimeError, RecursionError): | ||
| raise _fail("rows_invalid") from None | ||
| return tuple(rows) | ||
|
|
||
|
|
||
| def _snapshot_feed(value: object) -> dict[str, Any]: | ||
| if not isinstance(value, Mapping) or set(value) != _FEED_KEYS: | ||
| raise _fail("feed_shape_invalid") | ||
| try: | ||
| snapshot = dict(value) | ||
| except (TypeError, ValueError, RuntimeError): | ||
| raise _fail("feed_shape_invalid") from None | ||
| feed_id = _string(snapshot["feed_id"], "feed_invalid", allow_empty=False) | ||
| feed_url = _string(snapshot["feed_url"], "feed_invalid", allow_empty=False) | ||
| kind = _string(snapshot["kind"], "feed_invalid", allow_empty=False) | ||
| state = _string(snapshot["state"], "feed_invalid", allow_empty=False) | ||
| error_code = snapshot["error_code"] | ||
| if kind not in _KINDS or state not in _STATES or ( | ||
| error_code is not None and (type(error_code) is not str or not _SAFE_ERROR.fullmatch(error_code)) | ||
| ): | ||
| raise _fail("feed_invalid") | ||
| rows = _snapshot_rows(snapshot["rows"]) | ||
| if state == "accepted" and (not rows or error_code is not None): | ||
| raise _fail("feed_state_invalid") | ||
| if state == "failed" and (rows or not error_code): | ||
| raise _fail("feed_state_invalid") | ||
| if state == "quarantined" and not error_code: | ||
| raise _fail("feed_state_invalid") | ||
| return { | ||
| "feed_id": feed_id, | ||
| "feed_url": feed_url, | ||
| "kind": kind, | ||
| "state": state, | ||
| "rows": rows, | ||
| "error_code": error_code, | ||
| } | ||
|
|
||
|
|
||
| def _feed_wire(feed: Mapping[str, Any]) -> dict[str, object]: | ||
| rows = [row.to_mapping() for row in feed["rows"]] | ||
| return { | ||
| "feed_id": feed["feed_id"], | ||
| "feed_url": feed["feed_url"], | ||
| "kind": feed["kind"], | ||
| "state": feed["state"], | ||
| "accepted_row_count": len(rows), | ||
| "rejected_row_count": 0, | ||
| "row_digest": _digest(rows), | ||
| "error_code": feed["error_code"], | ||
| } | ||
|
|
||
|
|
||
| def build_status(feed_records: Iterable[Mapping[str, object]]) -> dict[str, object]: | ||
| if isinstance(feed_records, (str, bytes, Mapping)): | ||
| raise _fail("feed_records_invalid") | ||
| try: | ||
| records = [_snapshot_feed(item) for item in feed_records] | ||
| except PrimitiveStatusError: | ||
| raise | ||
| except (TypeError, ValueError, RuntimeError, RecursionError): | ||
| raise _fail("feed_records_invalid") from None | ||
| if not records: | ||
| raise _fail("configured_feed_empty") | ||
| if len({item["feed_id"] for item in records}) != len(records): | ||
| raise _fail("feed_duplicate") | ||
| if len(records) > MAX_SAFE_JSON_INTEGER: | ||
| raise _fail("feed_count_overflow") | ||
| records.sort(key=lambda item: (item["feed_id"], item["feed_url"])) | ||
| feeds = [_feed_wire(item) for item in records] | ||
| accepted = sum(item["state"] == "accepted" for item in records) | ||
| failed = sum(item["state"] == "failed" for item in records) | ||
| quarantined = sum(item["state"] == "quarantined" for item in records) | ||
| rows = [row.to_mapping() for item in records for row in item["rows"]] | ||
| complete = accepted == len(records) and failed == 0 and quarantined == 0 and bool(rows) | ||
| return { | ||
| "status_version": STATUS_VERSION, | ||
| "configured_feed_count": len(records), | ||
| "feed_count": len(records), | ||
| "successful_feed_count": accepted, | ||
| "failed_feed_count": failed, | ||
| "quarantined_feed_count": quarantined, | ||
| "accepted_row_count": len(rows), | ||
| "rejected_row_count": 0, | ||
| "publication_complete": complete, | ||
| "eligible_for_live_publication": complete, | ||
| "aggregate_row_digest": _digest(rows), | ||
| "feeds": feeds, | ||
| } | ||
|
|
||
|
|
||
| def _validate_wire(value: object) -> dict[str, object]: | ||
| if not isinstance(value, Mapping) or set(value) != _STATUS_KEYS: | ||
| raise _fail("status_shape_invalid") | ||
| try: | ||
| snapshot = dict(value) | ||
| except (TypeError, ValueError, RuntimeError): | ||
| raise _fail("status_shape_invalid") from None | ||
| if snapshot["status_version"] != STATUS_VERSION or type(snapshot["feeds"]) is not list: | ||
| raise _fail("status_shape_invalid") | ||
| integer_keys = ( | ||
| "configured_feed_count", | ||
| "feed_count", | ||
| "successful_feed_count", | ||
| "failed_feed_count", | ||
| "quarantined_feed_count", | ||
| "accepted_row_count", | ||
| "rejected_row_count", | ||
| ) | ||
| if any(_safe_int(snapshot[key], "status_counter_invalid") != snapshot[key] for key in integer_keys): | ||
| raise _fail("status_counter_invalid") | ||
| if any(type(snapshot[key]) is not bool for key in ("publication_complete", "eligible_for_live_publication")): | ||
| raise _fail("status_counter_invalid") | ||
| if snapshot["publication_complete"] != snapshot["eligible_for_live_publication"] or not _is_digest( | ||
| snapshot["aggregate_row_digest"] | ||
| ): | ||
| raise _fail("status_integrity_invalid") | ||
| if snapshot["configured_feed_count"] != snapshot["feed_count"] or snapshot["feed_count"] != len(snapshot["feeds"]): | ||
| raise _fail("status_counter_mismatch") | ||
| expected_counts = { | ||
| "successful_feed_count": 0, | ||
| "failed_feed_count": 0, | ||
| "quarantined_feed_count": 0, | ||
| "accepted_row_count": 0, | ||
| "rejected_row_count": 0, | ||
| } | ||
| feed_ids: set[str] = set() | ||
| feed_order: list[tuple[str, str]] = [] | ||
| for feed in snapshot["feeds"]: | ||
| if not isinstance(feed, Mapping) or set(feed) != _FEED_WIRE_KEYS: | ||
| raise _fail("feed_wire_shape_invalid") | ||
| if ( | ||
| any(type(feed[key]) is not str or not feed[key] for key in ("feed_id", "feed_url", "kind", "state")) | ||
| or feed["kind"] not in _KINDS | ||
| or feed["state"] not in _STATES | ||
| ): | ||
| raise _fail("feed_wire_shape_invalid") | ||
| if feed["feed_id"] in feed_ids: | ||
| raise _fail("feed_duplicate") | ||
| feed_ids.add(feed["feed_id"]) | ||
| feed_order.append((feed["feed_id"], feed["feed_url"])) | ||
| if ( | ||
| _safe_int(feed["accepted_row_count"], "feed_counter_invalid") != feed["accepted_row_count"] | ||
| or _safe_int(feed["rejected_row_count"], "feed_counter_invalid") != feed["rejected_row_count"] | ||
| ): | ||
| raise _fail("feed_counter_invalid") | ||
| if not _is_digest(feed["row_digest"]): | ||
| raise _fail("feed_digest_invalid") | ||
| state = feed["state"] | ||
| error = feed["error_code"] | ||
| if state not in _STATES or (error is not None and (type(error) is not str or not _SAFE_ERROR.fullmatch(error))): | ||
| raise _fail("feed_state_invalid") | ||
| if state == "accepted" and ( | ||
| feed["accepted_row_count"] <= 0 or feed["rejected_row_count"] != 0 or error is not None | ||
| ): | ||
| raise _fail("feed_state_invalid") | ||
| if state == "failed" and (feed["accepted_row_count"] != 0 or feed["rejected_row_count"] != 0 or not error): | ||
| raise _fail("feed_state_invalid") | ||
| if state == "quarantined" and not error: | ||
| raise _fail("feed_state_invalid") | ||
| expected_counts[f"{state if state != 'accepted' else 'successful'}_feed_count"] += 1 | ||
| expected_counts["accepted_row_count"] += feed["accepted_row_count"] | ||
| expected_counts["rejected_row_count"] += feed["rejected_row_count"] | ||
| if feed_order != sorted(feed_order): | ||
| raise _fail("feed_order_invalid") | ||
| if any(snapshot[key] != value for key, value in expected_counts.items()): | ||
| raise _fail("status_counter_mismatch") | ||
| complete = ( | ||
| snapshot["successful_feed_count"] == snapshot["feed_count"] | ||
| and snapshot["feed_count"] > 0 | ||
| and snapshot["accepted_row_count"] > 0 | ||
| ) | ||
| if snapshot["publication_complete"] != complete: | ||
| raise _fail("status_counter_mismatch") | ||
| return snapshot | ||
|
|
||
|
|
||
| def _is_digest(value: object) -> bool: | ||
| return type(value) is str and len(value) == 64 and all(char in "0123456789abcdef" for char in value) | ||
|
|
||
|
|
||
| def serialize_status(value: Mapping[str, object]) -> bytes: | ||
| return _canonical_bytes(_validate_wire(value)) | ||
|
|
||
|
|
||
| def parse_status_bytes(wire: bytes) -> dict[str, object]: | ||
| if type(wire) is not bytes: | ||
| raise _fail("status_wire_invalid") | ||
| def pairs(items: list[tuple[str, object]]) -> dict[str, object]: | ||
| result: dict[str, object] = {} | ||
| for key, item in items: | ||
| if key in result: | ||
| raise _fail("status_duplicate_key") | ||
| result[key] = item | ||
| return result | ||
| try: | ||
| value = json.loads(wire.decode("utf-8"), object_pairs_hook=pairs) | ||
| except (UnicodeError, json.JSONDecodeError, TypeError, ValueError, RecursionError): | ||
| raise _fail("status_wire_invalid") from None | ||
| parsed = _validate_wire(value) | ||
| if serialize_status(parsed) != wire: | ||
| raise _fail("status_noncanonical") | ||
| return parsed | ||
|
|
||
|
|
||
| def status_for_rows(wire: bytes, feed_records: Iterable[Mapping[str, object]]) -> dict[str, object]: | ||
| parse_status_bytes(wire) | ||
| expected = build_status(feed_records) | ||
| if serialize_status(expected) != wire: | ||
| raise _fail("status_integrity_mismatch") | ||
| return expected | ||
Oops, something went wrong.
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.
A caller can pass a
quarantinedfeed with anerror_codeand non-emptyrows, and this invariant accepts it;_feed_wirethen reports those rows asaccepted_row_countand includes them in the aggregate row digest even though the feed is not publication-eligible. If quarantined rows are meant to be withheld or rejected, this lets invalid primitive records be blessed by the public status builder.Useful? React with 👍 / 👎.