From 161d8da88245f93912abfbcc43a6dc5536cac3b3 Mon Sep 17 00:00:00 2001 From: Ed_ Date: Sun, 5 Jul 2026 17:07:04 -0400 Subject: [PATCH] feat(twitter_threads): scaffold package + error types + typed dataclasses TIER-2 READ AGENTS.md, conductor/workflow.md, conductor/edit_workflow.md, conductor/tier2/githooks/forbidden-files.txt, conductor/tracks/tier2_leak_prevention_20260620/spec.md (archived -> read docs/reports/2026-06-15/TRACK_COMPLETION_tier2_leak_prevention_20260620.md), conductor/code_styleguides/data_oriented_design.md, conductor/code_styleguides/python.md, conductor/code_styleguides/type_aliases.md, conductor/code_styleguides/error_handling.md, conductor/product-guidelines.md (Core Value via aggregate_directives) before Phase 1 (scaffold + error_types + dataclasses). scripts/twitter_threads/__init__.py (namespace docstring); error_types.py (ErrorInfo + make_error + PostMetrics/PostData/ThreadData, frozen+slots); tests/test_twitter_threads_types.py (9 contract tests). --- scripts/twitter_threads/__init__.py | 15 +++ scripts/twitter_threads/error_types.py | 37 +++++++ tests/test_twitter_threads_types.py | 127 +++++++++++++++++++++++++ 3 files changed, 179 insertions(+) create mode 100644 scripts/twitter_threads/__init__.py create mode 100644 scripts/twitter_threads/error_types.py create mode 100644 tests/test_twitter_threads_types.py 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"