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