diff --git a/scripts/twitter_threads/__init__.py b/scripts/twitter_threads/__init__.py new file mode 100644 index 00000000..33742b22 --- /dev/null +++ b/scripts/twitter_threads/__init__.py @@ -0,0 +1,15 @@ +"""Twitter/X thread extraction reusable tooling. + +Standalone scripts that acquire posts, threads, and media from X (Twitter) +and emit self-contained Markdown documents. + +Scripts in this namespace: +- fetch_thread.py: acquire a post/thread (gallery-dl subprocess for URLs; html.parser for local HTML) +- download_media.py: download referenced media via stdlib urllib +- render_markdown.py: emit the Markdown document + media links +- error_types.py: shared Result-style ErrorInfo + the ThreadData/PostData/PostMetrics dataclasses + +Standalone: no imports from src/ or conductor/; copy-pasteable to another repo with only gallery-dl as an external dep. + +Per conductor/code_styleguides/python.md, 1-space indent + type hints + no comments in implementation code. +""" diff --git a/scripts/twitter_threads/error_types.py b/scripts/twitter_threads/error_types.py new file mode 100644 index 00000000..010eb0e9 --- /dev/null +++ b/scripts/twitter_threads/error_types.py @@ -0,0 +1,37 @@ +"""Shared dataclasses for the scripts.twitter_threads namespace.""" +from __future__ import annotations +from dataclasses import dataclass + +@dataclass(frozen=True, slots=True) +class ErrorInfo: + kind: str + source: str + detail: str + +def make_error(kind: str, source: str, detail: str) -> ErrorInfo: + return ErrorInfo(kind=kind, source=source, detail=detail) + +@dataclass(frozen=True, slots=True) +class PostMetrics: + reply_count: int + repost_count: int + like_count: int + view_count: int | None + +@dataclass(frozen=True, slots=True) +class PostData: + post_id: str + author: str + handle: str + text: str + timestamp: str + media_urls: tuple[str, ...] + reply_to_id: str | None + quote_of_id: str | None + metrics: PostMetrics + +@dataclass(frozen=True, slots=True) +class ThreadData: + root_post_id: str + posts: tuple[PostData, ...] + source_url: str diff --git a/tests/test_twitter_threads_types.py b/tests/test_twitter_threads_types.py new file mode 100644 index 00000000..6cb896d4 --- /dev/null +++ b/tests/test_twitter_threads_types.py @@ -0,0 +1,127 @@ +"""Tests for scripts.twitter_threads.error_types dataclass contract.""" +from __future__ import annotations + +import dataclasses + +import pytest + +from scripts.twitter_threads.error_types import ErrorInfo, make_error, PostMetrics, PostData, ThreadData + + +def test_error_info_fields() -> None: + e = ErrorInfo(kind="ParseError", source="fetch", detail="bad html") + assert e.kind == "ParseError" + assert e.source == "fetch" + assert e.detail == "bad html" + + +def test_make_error_factory() -> None: + e = make_error("HttpError", "download_media", "404") + assert isinstance(e, ErrorInfo) + assert e.kind == "HttpError" + assert e.source == "download_media" + assert e.detail == "404" + + +def test_error_info_frozen() -> None: + e = ErrorInfo(kind="x", source="y", detail="z") + with pytest.raises(dataclasses.FrozenInstanceError): + e.kind = "w" + + +def test_error_info_slots() -> None: + e = ErrorInfo(kind="x", source="y", detail="z") + assert not hasattr(e, "__dict__") + + +def test_post_metrics() -> None: + m = PostMetrics(reply_count=1, repost_count=2, like_count=3, view_count=None) + assert m.reply_count == 1 + assert m.repost_count == 2 + assert m.like_count == 3 + assert m.view_count is None + m2 = PostMetrics(reply_count=10, repost_count=20, like_count=30, view_count=100) + assert m2.view_count == 100 + + +def test_post_data_fields() -> None: + metrics = PostMetrics(reply_count=1, repost_count=2, like_count=42, view_count=None) + p = PostData( + post_id="123", + author="Tim", + handle="NOTimothyLottes", + text="hi", + timestamp="2024-02-01T00:00:00Z", + media_urls=("https://x/i.jpg",), + reply_to_id=None, + quote_of_id=None, + metrics=metrics, + ) + assert p.post_id == "123" + assert p.author == "Tim" + assert p.handle == "NOTimothyLottes" + assert p.text == "hi" + assert p.timestamp == "2024-02-01T00:00:00Z" + assert isinstance(p.media_urls, tuple) + assert p.media_urls == ("https://x/i.jpg",) + assert p.reply_to_id is None + assert p.quote_of_id is None + assert p.metrics.like_count == 42 + + +def test_post_data_slots_frozen() -> None: + metrics = PostMetrics(reply_count=1, repost_count=2, like_count=3, view_count=None) + p = PostData( + post_id="123", + author="Tim", + handle="NOTimothyLottes", + text="hi", + timestamp="2024-02-01T00:00:00Z", + media_urls=(), + reply_to_id=None, + quote_of_id=None, + metrics=metrics, + ) + assert not hasattr(p, "__dict__") + with pytest.raises(dataclasses.FrozenInstanceError): + p.post_id = "456" + + +def test_thread_data_fields() -> None: + metrics = PostMetrics(reply_count=1, repost_count=2, like_count=3, view_count=None) + p = PostData( + post_id="123", + author="Tim", + handle="NOTimothyLottes", + text="hi", + timestamp="2024-02-01T00:00:00Z", + media_urls=(), + reply_to_id=None, + quote_of_id=None, + metrics=metrics, + ) + t = ThreadData(root_post_id="123", posts=(p,), source_url="https://x.com/NOTimothyLottes/status/123") + assert t.root_post_id == "123" + assert isinstance(t.posts, tuple) + assert len(t.posts) == 1 + assert t.posts[0] is p + assert t.source_url == "https://x.com/NOTimothyLottes/status/123" + + +def test_thread_data_slots_frozen() -> None: + metrics = PostMetrics(reply_count=1, repost_count=2, like_count=3, view_count=None) + p = PostData( + post_id="123", + author="Tim", + handle="NOTimothyLottes", + text="hi", + timestamp="2024-02-01T00:00:00Z", + media_urls=(), + reply_to_id=None, + quote_of_id=None, + metrics=metrics, + ) + t = ThreadData(root_post_id="123", posts=(p,), source_url="https://x.com/x/x/123") + assert not hasattr(t, "__dict__") + with pytest.raises(dataclasses.FrozenInstanceError): + t.source_url = "https://other"