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).
This commit is contained in:
@@ -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.
|
||||||
|
"""
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user