Private
Public Access
0
0

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:
2026-07-05 17:07:04 -04:00
parent 0908f8fa28
commit 161d8da882
3 changed files with 179 additions and 0 deletions
+15
View File
@@ -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.
"""
+37
View File
@@ -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
+127
View File
@@ -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"