From 4d2fe6b3d57109b25143676dcbb0ebe885dee78e Mon Sep 17 00:00:00 2001 From: patrickZWY <154947644+patrickZWY@users.noreply.github.com> Date: Tue, 12 May 2026 22:35:00 -0400 Subject: [PATCH 01/12] move cut text to comment section --- manual_testing/mastodon_manual_test.py | 20 ++-- social_posters/mastodon.py | 68 ++++++++++--- tests/test_mastodon.py | 130 +++++++++++++++++++++++-- 3 files changed, 188 insertions(+), 30 deletions(-) diff --git a/manual_testing/mastodon_manual_test.py b/manual_testing/mastodon_manual_test.py index b524729..294fc78 100644 --- a/manual_testing/mastodon_manual_test.py +++ b/manual_testing/mastodon_manual_test.py @@ -5,7 +5,7 @@ from abstractions import AdoptablePet from social_posters.mastodon import PosterMastodon -def postExceed500CharsLimitWithAdoptionLink(): +def post_exceed_500_chars_limit_with_adoption_link(): pet = AdoptablePet("Brian", "Labrador Retriever", "White Labrador", @@ -20,7 +20,7 @@ def postExceed500CharsLimitWithAdoptionLink(): ) return pet -def postExceed500CharsLimitWithoutAdoptionLink(): +def post_exceed_500_chars_limit_without_adoption_link(): pet = AdoptablePet("Vinny", "Unknown", "Unknown", @@ -36,7 +36,7 @@ def postExceed500CharsLimitWithoutAdoptionLink(): return pet -def postWithin500CharsLimitWithAdoptionLink(): +def post_within_500_chars_limit_with_adoption_link(): pet = AdoptablePet("Ernie", "Chicken", "Unknown", @@ -51,7 +51,7 @@ def postWithin500CharsLimitWithAdoptionLink(): ) return pet -def postWithin500CharsLimitWithoutAdoptionLink(): +def post_within_500_chars_limit_without_adoption_link(): pet = AdoptablePet("Pouncy", "Cat", "Unknown", @@ -66,7 +66,7 @@ def postWithin500CharsLimitWithoutAdoptionLink(): ) return pet -def postUnicode(): +def post_unicode(): pet = AdoptablePet("Vinny", "Unknown", "Unknown", @@ -82,11 +82,11 @@ def postUnicode(): return pet testingCases = [ - postExceed500CharsLimitWithAdoptionLink, - postExceed500CharsLimitWithoutAdoptionLink, - postWithin500CharsLimitWithAdoptionLink, - postWithin500CharsLimitWithoutAdoptionLink, - postUnicode + post_exceed_500_chars_limit_with_adoption_link, + post_exceed_500_chars_limit_without_adoption_link, + post_within_500_chars_limit_with_adoption_link, + post_within_500_chars_limit_without_adoption_link, + post_unicode ] def main(): diff --git a/social_posters/mastodon.py b/social_posters/mastodon.py index b6c73e1..83513d6 100644 --- a/social_posters/mastodon.py +++ b/social_posters/mastodon.py @@ -8,6 +8,7 @@ from abstractions import Post, PostResult, SocialPoster, AdoptablePet from abstractions import CITY_NAME, CITY_STATE +THREAD_SUFFIX = "\n\nMore details below ⬇️" MASTODON_CHARACTER_LIMIT = 500 TRUNCATION_SUFFIX = "..." @@ -69,10 +70,22 @@ def publish(self, post: Post) -> PostResult: image_path, description=post.alt_text or "Photo of an adoptable pet", ) + + main_caption, replies = self._format_caption_thread(post) + status = self._session.status_post( - self._format_caption(post), + main_caption, media_ids=[media["id"]], ) + + root_status_id = status["id"] + + for reply_text in replies: + self._session.status_post( + reply_text, + in_reply_to_id=root_status_id + ) + return PostResult( success=True, post_id=str(status["id"]), @@ -85,20 +98,42 @@ def publish(self, post: Post) -> PostResult: if image_path and os.path.exists(image_path): os.unlink(image_path) - def _format_caption(self, post: Post) -> str: + def _format_caption_thread(self, post: Post) -> tuple[str, list[str]]: tags = " ".join(f"#{tag}" for tag in post.tags if tag) tag_suffix = f"\n\n{tags}" if tags else "" - available_text_length = MASTODON_CHARACTER_LIMIT - len(tag_suffix) - - if available_text_length <= len(TRUNCATION_SUFFIX): - return (tag_suffix[-MASTODON_CHARACTER_LIMIT:]).strip() caption_text = post.text.strip() - if len(caption_text) > available_text_length: - caption_text = caption_text[: available_text_length - len(TRUNCATION_SUFFIX)].rstrip() - caption_text = f"{caption_text}{TRUNCATION_SUFFIX}" - return f"{caption_text}{tag_suffix}" + if len(caption_text) + len(tag_suffix) <= MASTODON_CHARACTER_LIMIT: + return f"{caption_text}{tag_suffix}", [] + + main_limit = ( + MASTODON_CHARACTER_LIMIT + - len(tag_suffix) + - len(THREAD_SUFFIX) + - len(TRUNCATION_SUFFIX) + ) + + main_text, overflow = self._safe_truncate(caption_text, main_limit) + + main_caption = f"{main_text}{TRUNCATION_SUFFIX}{THREAD_SUFFIX}{tag_suffix}" + replies = self._split_reply_chunks(overflow) + + + return main_caption, replies + + def _split_reply_chunks(self, text: str) -> list[str]: + chunks = [] + remaining = text.strip() + + while remaining: + chunk, remaining = self._safe_truncate( + remaining, + MASTODON_CHARACTER_LIMIT + ) + chunks.append(chunk) + + return chunks def _download_image(self, image_url: str) -> str: parsed_url = urlparse(image_url) @@ -110,9 +145,18 @@ def _download_image(self, image_url: str) -> str: if chunk: tmp.write(chunk) return tmp.name + + def _safe_truncate(self, text: str, limit: int) -> tuple[str, str]: + if len(text) <= limit: + return text, "" + + cut = text.rfind(" ", 0, limit) + + if cut == -1: + cut = limit + + return text[:cut].rstrip(), text[cut:].strip() - # rearrange so that link is at top - # need to test def format_post(self, pet:AdoptablePet) -> Post: """ Create a Post from an AdoptablePet. diff --git a/tests/test_mastodon.py b/tests/test_mastodon.py index aadc9c8..5dde1f3 100644 --- a/tests/test_mastodon.py +++ b/tests/test_mastodon.py @@ -6,22 +6,136 @@ class TestMastodonCaption: def setup_method(self): self.poster = PosterMastodon.__new__(PosterMastodon) + def reconstruct_text(self, main_caption: str, replies: list[str]) -> str: + main_without_tags = main_caption.split("\n\n#")[0] + main_without_suffix = ( + main_without_tags + .replace("...", "") + .replace("\n\nMore details below ⬇️", "") + .strip() + ) + + return " ".join([main_without_suffix] + replies).strip() + + def test_thread_preserves_original_text_content(self): + original_text = " ".join(f"word{i}" for i in range(300)) + post = Post(text=original_text, tags=["AdoptDontShop", "Boston"]) + + main_caption, replies = self.poster._format_caption_thread(post) + + reconstructed = self.reconstruct_text(main_caption, replies) + + assert reconstructed == original_text + + def test_thread_preserves_original_text_without_spaces(self): + original_text = "x" * 1000 + post = Post(text=original_text, tags=["AdoptDontShop", "Boston"]) + + main_caption, replies = self.poster._format_caption_thread(post) + + main_without_tags = main_caption.split("\n\n#")[0] + main_without_suffix = ( + main_without_tags + .replace("...", "") + .replace("\n\nMore details below ⬇️", "") + .strip() + ) + + reconstructed = main_without_suffix + "".join(replies) + + assert reconstructed == original_text + def test_no_tags(self): post = Post(text="Hello, world!") - assert self.poster._format_caption(post) == "Hello, world!" + main_caption, replies = self.poster._format_caption_thread(post) + assert main_caption == "Hello, world!" + assert replies == [] def test_with_tags(self): post = Post(text="Meet Poppy!", tags=["AdoptDontShop", "Boston"]) - assert self.poster._format_caption(post) == "Meet Poppy!\n\n#AdoptDontShop #Boston" + main_caption, replies = self.poster._format_caption_thread(post) + + assert main_caption == "Meet Poppy!\n\n#AdoptDontShop #Boston" + assert replies == [] - def test_caption_stays_under_limit(self): + def test_caption_stays_under_limit_and_creates_reply(self): + post = Post(text="x " * 1000, tags=["AdoptDontShop", "Boston"]) + main_caption, replies = self.poster._format_caption_thread(post) + + assert len(main_caption) <= MASTODON_CHARACTER_LIMIT + assert main_caption.endswith("\n\n#AdoptDontShop #Boston") + assert "..." in main_caption + assert "More details below" in main_caption + assert replies + assert all(len(reply) <= MASTODON_CHARACTER_LIMIT for reply in replies) + + def test_caption_stays_under_limit_creates_reply(self): post = Post(text="x" * 1000, tags=["AdoptDontShop", "Boston"]) - caption = self.poster._format_caption(post) + main_caption, replies = self.poster._format_caption_thread(post) - assert len(caption) <= MASTODON_CHARACTER_LIMIT - assert caption.endswith("\n\n#AdoptDontShop #Boston") - assert "..." in caption + assert len(main_caption) <= MASTODON_CHARACTER_LIMIT + assert main_caption.endswith("\n\n#AdoptDontShop #Boston") + assert "..." in main_caption + assert "More details below" in main_caption + assert replies + assert all(len(reply) <= MASTODON_CHARACTER_LIMIT for reply in replies) def test_empty_tags_are_ignored(self): post = Post(text="Meet Poppy!", tags=["AdoptDontShop", "", None, "Boston"]) - assert self.poster._format_caption(post) == "Meet Poppy!\n\n#AdoptDontShop #Boston" \ No newline at end of file + main_caption, replies = self.poster._format_caption_thread(post) + + assert main_caption == "Meet Poppy!\n\n#AdoptDontShop #Boston" + assert replies == [] + + def test_long_text_without_tags_creates_replies(self): + post = Post(text="hello " * 300) + main_caption, replies = self.poster._format_caption_thread(post) + + assert len(main_caption) <= MASTODON_CHARACTER_LIMIT + assert replies + assert all(len(reply) <= MASTODON_CHARACTER_LIMIT for reply in replies) + + def test_safe_truncate_does_not_split_words_when_possible(self): + kept, remaining = self.poster._safe_truncate("hello world again", 12) + assert kept == "hello world" + assert remaining == "again" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 782f6170b3222d42ff8f55652d0996b2abe40cdf Mon Sep 17 00:00:00 2001 From: patrickZWY <154947644+patrickZWY@users.noreply.github.com> Date: Tue, 12 May 2026 23:14:42 -0400 Subject: [PATCH 02/12] max replies allowed is 5 --- manual_testing/mastodon_preview_test.py | 25 ++++++++++++++++ social_posters/mastodon.py | 13 +++++++-- tests/test_mastodon.py | 39 ++++++++++++++++++++++++- 3 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 manual_testing/mastodon_preview_test.py diff --git a/manual_testing/mastodon_preview_test.py b/manual_testing/mastodon_preview_test.py new file mode 100644 index 0000000..386aa46 --- /dev/null +++ b/manual_testing/mastodon_preview_test.py @@ -0,0 +1,25 @@ +import os, sys +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from social_posters.mastodon import PosterMastodon +from mastodon_manual_test import post_exceed_500_chars_limit_with_adoption_link + +poster = PosterMastodon.__new__(PosterMastodon) + +pet = post_exceed_500_chars_limit_with_adoption_link() +post = poster.format_post(pet) + +main_caption, replies = poster._format_caption_thread(post) + +print("\n" + "=" * 60) +print("MAIN POST") +print("=" * 60) +print(main_caption) +print(f"\nLength: {len(main_caption)}") + +for i, reply in enumerate(replies, start=1): + print("\n" + "=" * 60) + print(f"REPLY {i}") + print("=" * 60) + print(reply) + print(f"\nLength: {len(reply)}") \ No newline at end of file diff --git a/social_posters/mastodon.py b/social_posters/mastodon.py index 83513d6..560f614 100644 --- a/social_posters/mastodon.py +++ b/social_posters/mastodon.py @@ -11,7 +11,7 @@ THREAD_SUFFIX = "\n\nMore details below ⬇️" MASTODON_CHARACTER_LIMIT = 500 TRUNCATION_SUFFIX = "..." - +MAX_REPLIES = 5 class PosterMastodon(SocialPoster): def __init__(self): @@ -126,12 +126,21 @@ def _split_reply_chunks(self, text: str) -> list[str]: chunks = [] remaining = text.strip() - while remaining: + while remaining and len(chunks) < MAX_REPLIES: chunk, remaining = self._safe_truncate( remaining, MASTODON_CHARACTER_LIMIT ) chunks.append(chunk) + + if remaining and chunks: + last_chunk = chunks[-1] + + cutoff = MASTODON_CHARACTER_LIMIT - len(TRUNCATION_SUFFIX) + + last_chunk, _ = self._safe_truncate(last_chunk, cutoff) + + chunks[-1] = f"{last_chunk}{TRUNCATION_SUFFIX}" return chunks diff --git a/tests/test_mastodon.py b/tests/test_mastodon.py index 5dde1f3..b3164f0 100644 --- a/tests/test_mastodon.py +++ b/tests/test_mastodon.py @@ -1,5 +1,5 @@ from abstractions import Post -from social_posters.mastodon import PosterMastodon, MASTODON_CHARACTER_LIMIT +from social_posters.mastodon import PosterMastodon, MASTODON_CHARACTER_LIMIT, MAX_REPLIES class TestMastodonCaption: @@ -16,6 +16,43 @@ def reconstruct_text(self, main_caption: str, replies: list[str]) -> str: ) return " ".join([main_without_suffix] + replies).strip() + + def test_reply_count_is_capped(self): + post = Post(text="hello " * 5000) + + _, replies = self.poster._format_caption_thread(post) + + assert len(replies) <= MAX_REPLIES + + def test_last_reply_has_truncation_suffix_when_capped(self): + post = Post(text="hello " * 5000) + + _, replies = self.poster._format_caption_thread(post) + + assert len(replies) == MAX_REPLIES + assert replies[-1].endswith("...") + assert len(replies[-1]) <= MASTODON_CHARACTER_LIMIT + + def test_last_reply_has_no_truncation_suffix_when_not_capped(self): + post = Post(text="hello " * 300) + + _, replies = self.poster._format_caption_thread(post) + + assert replies + assert len(replies) < MAX_REPLIES + assert not replies[-1].endswith("...") + + def test_capped_thread_does_not_preserve_all_original_text(self): + original_text = "hello " * 5000 + post = Post(text=original_text) + + main_caption, replies = self.poster._format_caption_thread(post) + + reconstructed = self.reconstruct_text(main_caption, replies) + + assert len(replies) == MAX_REPLIES + assert reconstructed != original_text + assert replies[-1].endswith("...") def test_thread_preserves_original_text_content(self): original_text = " ".join(f"word{i}" for i in range(300)) From 0e45ec41c2f9cf4b331fe595afb03928034a6b5b Mon Sep 17 00:00:00 2001 From: patrickZWY <154947644+patrickZWY@users.noreply.github.com> Date: Tue, 12 May 2026 23:17:53 -0400 Subject: [PATCH 03/12] remove white space --- tests/test_mastodon.py | 42 +----------------------------------------- 1 file changed, 1 insertion(+), 41 deletions(-) diff --git a/tests/test_mastodon.py b/tests/test_mastodon.py index b3164f0..e399011 100644 --- a/tests/test_mastodon.py +++ b/tests/test_mastodon.py @@ -135,44 +135,4 @@ def test_long_text_without_tags_creates_replies(self): def test_safe_truncate_does_not_split_words_when_possible(self): kept, remaining = self.poster._safe_truncate("hello world again", 12) assert kept == "hello world" - assert remaining == "again" - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + assert remaining == "again" \ No newline at end of file From e33e1c1e1c13838a3174fb466b0b3eb4cf6f9334 Mon Sep 17 00:00:00 2001 From: patrickZWY <154947644+patrickZWY@users.noreply.github.com> Date: Wed, 13 May 2026 11:19:01 -0400 Subject: [PATCH 04/12] fix conflict --- manual_testing/mastodon_preview_test.py | 106 +++++++-- requirements.txt | 12 ++ social_posters/mastodon.py | 275 +++++++++++++++++------- tests/test_mastodon.py | 65 ++++++ 4 files changed, 368 insertions(+), 90 deletions(-) diff --git a/manual_testing/mastodon_preview_test.py b/manual_testing/mastodon_preview_test.py index 386aa46..7362583 100644 --- a/manual_testing/mastodon_preview_test.py +++ b/manual_testing/mastodon_preview_test.py @@ -1,25 +1,97 @@ -import os, sys +import argparse +import os +import sys +from dataclasses import asdict +from enum import StrEnum +from pprint import pprint + sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -from social_posters.mastodon import PosterMastodon from mastodon_manual_test import post_exceed_500_chars_limit_with_adoption_link +from social_posters.mastodon import PosterMastodon -poster = PosterMastodon.__new__(PosterMastodon) - -pet = post_exceed_500_chars_limit_with_adoption_link() -post = poster.format_post(pet) - -main_caption, replies = poster._format_caption_thread(post) +""" +How to use this to see each stage of the pipeline: +see pet information: +python manual_tests/mastodon_preview.py --stage pet +see post information (platform ready but not mastodon processed): +python manual_tests/mastodon_preview.py --stage post +see full formatting in mastodon: +python manual_tests/mastodon_preview.py --stage debug +see only main thread part of the formatting in mastodon: +python manual_tests/mastodon_preview.py --stage main +see only replies part of the formatting in mastodon: +python manual_tests/mastodon_preview.py --stage replies +see all stages: +python manual_tests/mastodon_preview.py --stage all (or no arg default to all) +""" +class PreviewStage(StrEnum): + PET = "pet" + POST = "post" + DEBUG = "debug" + MAIN = "main" + REPLIES = "replies" + ALL = "all" -print("\n" + "=" * 60) -print("MAIN POST") -print("=" * 60) -print(main_caption) -print(f"\nLength: {len(main_caption)}") -for i, reply in enumerate(replies, start=1): +def print_section(title: str) -> None: print("\n" + "=" * 60) - print(f"REPLY {i}") + print(title) print("=" * 60) - print(reply) - print(f"\nLength: {len(reply)}") \ No newline at end of file + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Preview each stage of the Mastodon formatting pipeline." + ) + parser.add_argument( + "--stage", + type=PreviewStage, + choices=list(PreviewStage), + default=PreviewStage.ALL, + help="Which construction stage to preview.", + ) + return parser.parse_args() + + +def should_show(selected: PreviewStage, target: PreviewStage) -> bool: + return selected in (target, PreviewStage.ALL) + + +def main() -> None: + args = parse_args() + stage: PreviewStage = args.stage + + poster = PosterMastodon.__new__(PosterMastodon) + + pet = post_exceed_500_chars_limit_with_adoption_link() + post = poster.format_post(pet) + + main_caption, replies, debug = poster._format_caption_thread_with_trace(post) + + if should_show(stage, PreviewStage.PET): + print_section("PET") + pprint(pet) + + if should_show(stage, PreviewStage.POST): + print_section("POST OBJECT") + pprint(post) + + if should_show(stage, PreviewStage.DEBUG): + print_section("DEBUG PIPELINE") + pprint(asdict(debug)) + + if should_show(stage, PreviewStage.MAIN): + print_section("MAIN POST") + print(main_caption) + print(f"\nLength: {len(main_caption)}") + + if should_show(stage, PreviewStage.REPLIES): + for i, reply in enumerate(replies, start=1): + print_section(f"REPLY {i}") + print(reply) + print(f"\nLength: {len(reply)}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 1b9bdcb..25519fc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,7 @@ certifi chardet==3.0.4 charset-normalizer clarifai==2.6.2 +click==8.3.3 configparser==3.8.1 decorator EasyProcess==1.1 @@ -17,14 +18,21 @@ grpcio==1.78.0 h11==0.16.0 httpcore==1.0.9 httpx==0.28.1 +hypothesis==6.152.7 idna==2.10 +iniconfig==2.3.0 instapy==0.6.16 jsonschema==2.6.0 Mastodon.py MeaningCloud-python==2.0.0 outcome==1.3.0.post0 +packaging==26.2 +pip-tools==7.5.3 +pluggy==1.6.0 plyer==2.1.0 protobuf==3.20.3 +Pygments==2.20.0 +pyproject_hooks==1.2.0 PySocks==1.7.1 certifi==2026.4.22 charset-normalizer==3.4.7 @@ -33,6 +41,10 @@ idna Mastodon.py==2.2.1 python-dateutil==2.9.0.post0 requests==2.33.1 +setuptools==82.0.0 six==1.17.0 +sortedcontainers==2.4.0 +typing_extensions==4.15.0 urllib3==2.6.3 pytest +wheel==0.47.0 diff --git a/social_posters/mastodon.py b/social_posters/mastodon.py index 560f614..60c0e57 100644 --- a/social_posters/mastodon.py +++ b/social_posters/mastodon.py @@ -1,11 +1,12 @@ import os -from urllib.parse import urlparse import tempfile +from dataclasses import dataclass, field +from urllib.parse import urlparse import requests from mastodon import Mastodon -from abstractions import Post, PostResult, SocialPoster, AdoptablePet +from abstractions import AdoptablePet, Post, PostResult, SocialPoster from abstractions import CITY_NAME, CITY_STATE THREAD_SUFFIX = "\n\nMore details below ⬇️" @@ -13,14 +14,30 @@ TRUNCATION_SUFFIX = "..." MAX_REPLIES = 5 + +@dataclass +class MastodonFormatTrace: + raw_text: str + caption_text: str + tags: list[str] + tag_suffix: str + main_limit: int | None = None + main_text: str | None = None + overflow: str | None = None + main_caption: str | None = None + replies: list[str] = field(default_factory=list) + was_split: bool = False + was_capped: bool = False + + class PosterMastodon(SocialPoster): - def __init__(self): + def __init__(self) -> None: raw_token = os.environ.get("MASTODON_TOKEN") self.token = raw_token.strip() if raw_token else None self.api_base_url = "https://mastodon.social" - self._session = None + self._session: Mastodon | None = None self._is_available = bool(self.token) - self._auth_error = None + self._auth_error: str | None = None @property def platform_name(self) -> str: @@ -41,6 +58,38 @@ def authenticate(self) -> bool: return False def publish(self, post: Post) -> PostResult: + error = self._ensure_ready_to_publish(post) + if error: + return error + + image_path = None + + try: + image_path = self._download_image(post.image_url) + + media = self._session.media_post( + image_path, + description=post.alt_text or "Photo of an adoptable pet", + ) + + main_caption, replies = self._format_caption_thread(post) + status = self._post_thread(main_caption, replies, media["id"]) + + return PostResult( + success=True, + post_id=str(status["id"]), + post_url=status.get("url"), + ) + + except Exception as exc: + return PostResult(success=False, error_message=str(exc)) + + finally: + self._session = None + if image_path and os.path.exists(image_path): + os.unlink(image_path) + + def _ensure_ready_to_publish(self, post: Post) -> PostResult | None: if not self._is_available: return PostResult( success=False, @@ -63,65 +112,144 @@ def publish(self, post: Post) -> PostResult: ), ) - image_path = None - try: - image_path = self._download_image(post.image_url) - media = self._session.media_post( - image_path, - description=post.alt_text or "Photo of an adoptable pet", - ) + return None + + def _post_thread( + self, + main_caption: str, + replies: list[str], + media_id: str, + ) -> dict: + status = self._session.status_post( + main_caption, + media_ids=[media_id], + ) - main_caption, replies = self._format_caption_thread(post) + root_status_id = status["id"] - status = self._session.status_post( - main_caption, - media_ids=[media["id"]], + for reply_text in replies: + self._session.status_post( + reply_text, + in_reply_to_id=root_status_id, ) - root_status_id = status["id"] + return status - for reply_text in replies: - self._session.status_post( - reply_text, - in_reply_to_id=root_status_id - ) + def _format_caption_thread_with_trace( + self, + post: Post, + ) -> tuple[str, list[str], MastodonFormatTrace]: + caption_text, tag_suffix, trace = self._prepare_caption(post) - return PostResult( - success=True, - post_id=str(status["id"]), - post_url=status.get("url"), - ) - except Exception as exc: - return PostResult(success=False, error_message=str(exc)) - finally: - self._session = None - if image_path and os.path.exists(image_path): - os.unlink(image_path) + if self._fits_single_post(caption_text, tag_suffix): + return self._build_single_post(caption_text, tag_suffix, trace) + + main_limit = self._validated_main_limit(tag_suffix) + main_text, overflow = self._safe_truncate(caption_text, main_limit) + replies = self._split_reply_chunks(overflow) + main_caption = self._build_main_caption(main_text, tag_suffix) + + trace = self._finalize_trace( + trace=trace, + main_limit=main_limit, + main_text=main_text, + overflow=overflow, + main_caption=main_caption, + replies=replies, + ) + + return main_caption, replies, trace def _format_caption_thread(self, post: Post) -> tuple[str, list[str]]: - tags = " ".join(f"#{tag}" for tag in post.tags if tag) - tag_suffix = f"\n\n{tags}" if tags else "" + main_caption, replies, _ = self._format_caption_thread_with_trace(post) + return main_caption, replies + def _prepare_caption( + self, + post: Post, + ) -> tuple[str, str, MastodonFormatTrace]: + tags, tag_suffix = self._format_tags(post.tags) caption_text = post.text.strip() - if len(caption_text) + len(tag_suffix) <= MASTODON_CHARACTER_LIMIT: - return f"{caption_text}{tag_suffix}", [] + trace = MastodonFormatTrace( + raw_text=post.text, + caption_text=caption_text, + tags=tags, + tag_suffix=tag_suffix, + ) - main_limit = ( - MASTODON_CHARACTER_LIMIT - - len(tag_suffix) - - len(THREAD_SUFFIX) - - len(TRUNCATION_SUFFIX) + return caption_text, tag_suffix, trace + + @staticmethod + def _fits_single_post(caption_text: str, tag_suffix: str) -> bool: + return len(caption_text) + len(tag_suffix) <= MASTODON_CHARACTER_LIMIT + + @staticmethod + def _build_single_post( + caption_text: str, + tag_suffix: str, + trace: MastodonFormatTrace, + ) -> tuple[str, list[str], MastodonFormatTrace]: + main_caption = f"{caption_text}{tag_suffix}" + trace.main_caption = main_caption + return main_caption, [], trace + + def _validated_main_limit(self, tag_suffix: str) -> int: + main_limit = self._main_caption_limit(tag_suffix) + + if main_limit <= 0: + raise ValueError("Tags are too long to fit in a Mastodon post.") + + return main_limit + + @staticmethod + def _build_main_caption(main_text: str, tag_suffix: str) -> str: + return ( + f"{main_text}" + f"{TRUNCATION_SUFFIX}" + f"{THREAD_SUFFIX}" + f"{tag_suffix}" ) - main_text, overflow = self._safe_truncate(caption_text, main_limit) - - main_caption = f"{main_text}{TRUNCATION_SUFFIX}{THREAD_SUFFIX}{tag_suffix}" - replies = self._split_reply_chunks(overflow) + @staticmethod + def _finalize_trace( + trace: MastodonFormatTrace, + main_limit: int, + main_text: str, + overflow: str, + main_caption: str, + replies: list[str], + ) -> MastodonFormatTrace: + trace.main_limit = main_limit + trace.main_text = main_text + trace.overflow = overflow + trace.replies = replies + trace.main_caption = main_caption + trace.was_split = True + trace.was_capped = ( + replies[-1].endswith(TRUNCATION_SUFFIX) + if replies + else False + ) + return trace + + @staticmethod + def _format_tags(tags: list[str]) -> tuple[list[str], str]: + clean_tags = [tag for tag in tags if tag] + tag_text = " ".join(f"#{tag}" for tag in clean_tags) + tag_suffix = f"\n\n{tag_text}" if tag_text else "" + return clean_tags, tag_suffix + + @staticmethod + def _main_caption_limit(tag_suffix: str) -> int: + return ( + MASTODON_CHARACTER_LIMIT + - len(tag_suffix) + - len(THREAD_SUFFIX) + - len(TRUNCATION_SUFFIX) + ) - return main_caption, replies - def _split_reply_chunks(self, text: str) -> list[str]: chunks = [] remaining = text.strip() @@ -129,17 +257,13 @@ def _split_reply_chunks(self, text: str) -> list[str]: while remaining and len(chunks) < MAX_REPLIES: chunk, remaining = self._safe_truncate( remaining, - MASTODON_CHARACTER_LIMIT + MASTODON_CHARACTER_LIMIT, ) chunks.append(chunk) - - if remaining and chunks: - last_chunk = chunks[-1] + if remaining and chunks: cutoff = MASTODON_CHARACTER_LIMIT - len(TRUNCATION_SUFFIX) - - last_chunk, _ = self._safe_truncate(last_chunk, cutoff) - + last_chunk, _ = self._safe_truncate(chunks[-1], cutoff) chunks[-1] = f"{last_chunk}{TRUNCATION_SUFFIX}" return chunks @@ -147,15 +271,19 @@ def _split_reply_chunks(self, text: str) -> list[str]: def _download_image(self, image_url: str) -> str: parsed_url = urlparse(image_url) ext = os.path.splitext(parsed_url.path)[1] or ".jpg" + with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp: - response = requests.get(image_url, stream=True, timeout=20) - response.raise_for_status() - for chunk in response.iter_content(chunk_size=1024 * 128): - if chunk: - tmp.write(chunk) + with requests.get(image_url, stream=True, timeout=20) as response: + response.raise_for_status() + + for chunk in response.iter_content(chunk_size=1024 * 128): + if chunk: + tmp.write(chunk) + return tmp.name - - def _safe_truncate(self, text: str, limit: int) -> tuple[str, str]: + + @staticmethod + def _safe_truncate(text: str, limit: int) -> tuple[str, str]: if len(text) <= limit: return text, "" @@ -163,20 +291,18 @@ def _safe_truncate(self, text: str, limit: int) -> tuple[str, str]: if cut == -1: cut = limit - - return text[:cut].rstrip(), text[cut:].strip() - def format_post(self, pet:AdoptablePet) -> Post: - """ - Create a Post from an AdoptablePet. + return text[:cut].rstrip(), text[cut:].strip() - Override this method to customize post formatting for specific platforms. - """ - text = f"Meet {pet.name}! This adorable {pet.breed} {pet.species} is looking for a forever home in {pet.location}." + def format_post(self, pet: AdoptablePet) -> Post: + text = ( + f"Meet {pet.name}! This adorable {pet.breed} {pet.species} " + f"is looking for a forever home in {pet.location}." + ) if pet.adoption_url: - text += f" Adopt {pet.name}: {pet.adoption_url}" - + text += f" Adopt {pet.name}: {pet.adoption_url}" + if pet.description: text += f"\n\n{pet.description}" @@ -188,7 +314,10 @@ def format_post(self, pet:AdoptablePet) -> Post: text=text, image_url=pet.image_url, link=pet.adoption_url, - alt_text=f"Photo of {pet.name}, a {pet.breed} {pet.species} available for adoption", + alt_text=( + f"Photo of {pet.name}, a {pet.breed} {pet.species} " + "available for adoption" + ), tags=[ "adoptdontshop", "rescue", diff --git a/tests/test_mastodon.py b/tests/test_mastodon.py index e399011..e8154b9 100644 --- a/tests/test_mastodon.py +++ b/tests/test_mastodon.py @@ -1,5 +1,70 @@ from abstractions import Post from social_posters.mastodon import PosterMastodon, MASTODON_CHARACTER_LIMIT, MAX_REPLIES +from hypothesis import given, strategies as st + +tag_strategy = st.lists( + st.one_of(st.text(min_size=0, max_size=20), st.none()), + max_size=10 +) + +text_strategy = st.text( + alphabet=st.characters(blacklist_categories=("Cs",)), + min_size=0, + max_size=5000 +) + +class TestMastodonCaptionProperties: + def setup_method(self): + self.poster = PosterMastodon.__new__(PosterMastodon) + + @given(text=text_strategy, tags=tag_strategy) + def test_all_parts_stay_under_mastodon_limit(self, text, tags): + post = Post(text=text, tags=tags) + + main_caption, replies = self.poster._format_caption_thread(post) + + assert len(main_caption) <= MASTODON_CHARACTER_LIMIT + assert all(len(reply) <= MASTODON_CHARACTER_LIMIT for reply in replies) + + @given(text=text_strategy, tags=tag_strategy) + def test_reply_count_is_never_over_cap(self, text, tags): + post = Post(text=text, tags=tags) + + _, replies = self.poster._format_caption_thread(post) + + assert len(replies) <= MAX_REPLIES + + @given(text=st.text(min_size=0, max_size=300)) + def test_no_empty_replies(self, text): + post = Post(text=text) + + _, replies = self.poster._format_caption_thread(post) + + assert all(reply for reply in replies) + + @given(text=text_strategy, tags=tag_strategy) + def test_debug_matches_normal_formatter(self, text, tags): + post = Post(text=text, tags=tags) + + main_caption, replies = self.poster._format_caption_thread(post) + debug_main, debug_replies, debug = self.poster._format_caption_thread_with_trace(post) + + assert debug_main == main_caption + assert debug_replies == replies + assert debug.main_caption == main_caption + assert debug.replies == replies + + @given(text=text_strategy, tags=tag_strategy) + def test_trace_matches_regular_formatter(self, text, tags): + post = Post(text=text, tags=tags) + + main_caption, replies = self.poster._format_caption_thread(post) + trace_main, trace_replies, trace = self.poster._format_caption_thread_with_trace(post) + + assert trace_main == main_caption + assert trace_replies == replies + assert trace.main_caption == main_caption + assert trace.replies == replies class TestMastodonCaption: From c6219762d73ca842b45bbb6b915f4d223e64568a Mon Sep 17 00:00:00 2001 From: patrickZWY <154947644+patrickZWY@users.noreply.github.com> Date: Wed, 13 May 2026 11:30:33 -0400 Subject: [PATCH 05/12] refactor preview --- manual_testing/mastodon_preview_test.py | 74 +++++++++++++++++-------- 1 file changed, 50 insertions(+), 24 deletions(-) diff --git a/manual_testing/mastodon_preview_test.py b/manual_testing/mastodon_preview_test.py index 7362583..a737c47 100644 --- a/manual_testing/mastodon_preview_test.py +++ b/manual_testing/mastodon_preview_test.py @@ -4,6 +4,7 @@ from dataclasses import asdict from enum import StrEnum from pprint import pprint +from typing import Callable sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) @@ -67,30 +68,55 @@ def main() -> None: pet = post_exceed_500_chars_limit_with_adoption_link() post = poster.format_post(pet) - main_caption, replies, debug = poster._format_caption_thread_with_trace(post) - - if should_show(stage, PreviewStage.PET): - print_section("PET") - pprint(pet) - - if should_show(stage, PreviewStage.POST): - print_section("POST OBJECT") - pprint(post) - - if should_show(stage, PreviewStage.DEBUG): - print_section("DEBUG PIPELINE") - pprint(asdict(debug)) - - if should_show(stage, PreviewStage.MAIN): - print_section("MAIN POST") - print(main_caption) - print(f"\nLength: {len(main_caption)}") - - if should_show(stage, PreviewStage.REPLIES): - for i, reply in enumerate(replies, start=1): - print_section(f"REPLY {i}") - print(reply) - print(f"\nLength: {len(reply)}") + main_caption, replies, trace = poster._format_caption_thread_with_trace(post) + + sections: list[tuple[PreviewStage, str, Callable[[], None]]] = [ + ( + PreviewStage.PET, + "PET", + lambda: pprint(pet), + ), + ( + PreviewStage.POST, + "POST OBJECT", + lambda: pprint(post), + ), + ( + PreviewStage.DEBUG, + "DEBUG PIPELINE", + lambda: pprint(asdict(trace)), + ), + ( + PreviewStage.MAIN, + "MAIN POST", + lambda: print_main_caption(main_caption), + ), + ( + PreviewStage.REPLIES, + "REPLIES", + lambda: print_replies(replies), + ), + ] + + for target_stage, title, renderer in sections: + if should_show(stage, target_stage): + print_section(title) + renderer() + + +def print_main_caption(main_caption: str) -> None: + print(main_caption) + print(f"\nLength: {len(main_caption)}") + + +def print_replies(replies: list[str]) -> None: + if not replies: + print("(No replies)") + + for i, reply in enumerate(replies, start=1): + print_section(f"REPLY {i}") + print(reply) + print(f"\nLength: {len(reply)}") if __name__ == "__main__": From 222e4e79d8f4cccac322a56e55dcd60a33903830 Mon Sep 17 00:00:00 2001 From: patrickZWY <154947644+patrickZWY@users.noreply.github.com> Date: Wed, 13 May 2026 11:57:07 -0400 Subject: [PATCH 06/12] change name for better understanding of matching user arg to choices --- manual_testing/mastodon_preview_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/manual_testing/mastodon_preview_test.py b/manual_testing/mastodon_preview_test.py index a737c47..314e506 100644 --- a/manual_testing/mastodon_preview_test.py +++ b/manual_testing/mastodon_preview_test.py @@ -55,7 +55,7 @@ def parse_args() -> argparse.Namespace: return parser.parse_args() -def should_show(selected: PreviewStage, target: PreviewStage) -> bool: +def includes_stage(selected: PreviewStage, target: PreviewStage) -> bool: return selected in (target, PreviewStage.ALL) @@ -99,7 +99,7 @@ def main() -> None: ] for target_stage, title, renderer in sections: - if should_show(stage, target_stage): + if includes_stage(stage, target_stage): print_section(title) renderer() From 3423d8e73076df029006ea3f828212459fe7168c Mon Sep 17 00:00:00 2001 From: patrickZWY <154947644+patrickZWY@users.noreply.github.com> Date: Thu, 14 May 2026 17:48:32 -0700 Subject: [PATCH 07/12] documentation --- manual_testing/mastodon_manual_test.py | 2 ++ manual_testing/mastodon_preview_test.py | 2 ++ social_posters/mastodon.py | 28 +++++++++++++++++++++- tests/test_mastodon.py | 31 +++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 1 deletion(-) diff --git a/manual_testing/mastodon_manual_test.py b/manual_testing/mastodon_manual_test.py index 294fc78..de9e587 100644 --- a/manual_testing/mastodon_manual_test.py +++ b/manual_testing/mastodon_manual_test.py @@ -81,6 +81,8 @@ def post_unicode(): ) return pet +# !!!DO NOT TEST MULTIPLE CASES AT THE SAME TIME!!! +# !!!OTHERWISE YOU MAY TRIGGER SPAM DETECTION!!! testingCases = [ post_exceed_500_chars_limit_with_adoption_link, post_exceed_500_chars_limit_without_adoption_link, diff --git a/manual_testing/mastodon_preview_test.py b/manual_testing/mastodon_preview_test.py index 314e506..643a8af 100644 --- a/manual_testing/mastodon_preview_test.py +++ b/manual_testing/mastodon_preview_test.py @@ -25,6 +25,8 @@ python manual_tests/mastodon_preview.py --stage replies see all stages: python manual_tests/mastodon_preview.py --stage all (or no arg default to all) +Extend sections to be previewed by modifying the PreviewStage class and add +an action to sections """ class PreviewStage(StrEnum): PET = "pet" diff --git a/social_posters/mastodon.py b/social_posters/mastodon.py index 60c0e57..70a4ab3 100644 --- a/social_posters/mastodon.py +++ b/social_posters/mastodon.py @@ -14,7 +14,33 @@ TRUNCATION_SUFFIX = "..." MAX_REPLIES = 5 - +""" +Mastodon implementation of the Cute Pets Boston Project +Requires: MASTODON_TOKEN for authentication + +The domain that hosts our account (Mastodon.social) has +a 500 chars limit, we prioritize the adoption link at +the top of the post by overriding format_post, then +split the exceeding chars into the replies section +with number of replies capped at the MAX_REPLIES. If post +content does not exceed limit, no replies generated. If +replies exceed MAX_REPLIES, truncate it with `...` . Replies +are text-only and no media attached. + +Use the preview file within manual_testing folder to inspect +each phase of the pipeline when developing or debugging +Mastodon due to its complexity from split text. There, +you can choose to inspect only the pet, the formatted +post, the main post, the replies, or the trace itself that +contains the properties of the post. Use safe truncation to +prevent cutting off words when splitting text. Use +_format_caption_thread_with_trace to create split text with +trace. + +Pipeline: AdoptablePet => format_post (override formatting) => +_format_caption_thread_with_trace(Mastodon splitting) => +publish main status => publish replies (if needed) => PostResult +""" @dataclass class MastodonFormatTrace: raw_text: str diff --git a/tests/test_mastodon.py b/tests/test_mastodon.py index e8154b9..19a5e0f 100644 --- a/tests/test_mastodon.py +++ b/tests/test_mastodon.py @@ -2,6 +2,37 @@ from social_posters.mastodon import PosterMastodon, MASTODON_CHARACTER_LIMIT, MAX_REPLIES from hypothesis import given, strategies as st +""" +Testing includes: +1. Property-based testing (Hypothesis) +2. Unit tests +3. Manual visual inspection in Preview file + +Property-based tests: +- Generate randomized text/tag combinations. +- Nondeterministic testing unless specify seeds. +- Verify global invariants such as: + * captions never exceed Mastodon limits + * replies never exceed MAX_REPLIES + * formatter trace matches normal formatter + * no empty replies are produced + +Unit tests: +- Validate specific expected behaviors and edge cases. +- Examples include: + * truncation behavior + * capped thread handling + * reconstruction correctness + * no-tag formatting + * tag filtering + * word-safe truncation + * long text handling + +Manual visual inspection: +- See Preview file for details +""" + +# Generation rules for tags/texts used in testing tag_strategy = st.lists( st.one_of(st.text(min_size=0, max_size=20), st.none()), max_size=10 From ae332ce98827a1941a798b3e86a27fab7a48362c Mon Sep 17 00:00:00 2001 From: patrickZWY <154947644+patrickZWY@users.noreply.github.com> Date: Sun, 17 May 2026 11:46:43 -0700 Subject: [PATCH 08/12] refactor pipeline --- manual_testing/mastodon_manual_test.py | 2 +- manual_testing/mastodon_preview_test.py | 175 ++++++++++++++------- social_posters/mastodon.py | 201 ++++++++++++------------ tests/test_mastodon.py | 74 ++++++--- utils/pipeline.py | 60 +++++++ utils/pipeline_preview.py | 33 ++++ 6 files changed, 364 insertions(+), 181 deletions(-) create mode 100644 utils/pipeline.py create mode 100644 utils/pipeline_preview.py diff --git a/manual_testing/mastodon_manual_test.py b/manual_testing/mastodon_manual_test.py index de9e587..369eaa4 100644 --- a/manual_testing/mastodon_manual_test.py +++ b/manual_testing/mastodon_manual_test.py @@ -10,7 +10,7 @@ def post_exceed_500_chars_limit_with_adoption_link(): "Labrador Retriever", "White Labrador", "Quahog", - "I am a writer! Post exceeds limit with adoption link"*1000, + "I am a writer! Post exceeds limit with adoption link"*200, "http://www.davidgorman.com/4quartets/", "https://static.wikia.nocookie.net/familyguy/images/c/c2/FamilyGuy_Single_BrianWriter_R7.jpg/revision/latest?cb=20230807152447", 11, diff --git a/manual_testing/mastodon_preview_test.py b/manual_testing/mastodon_preview_test.py index 643a8af..c71e127 100644 --- a/manual_testing/mastodon_preview_test.py +++ b/manual_testing/mastodon_preview_test.py @@ -1,16 +1,3 @@ -import argparse -import os -import sys -from dataclasses import asdict -from enum import StrEnum -from pprint import pprint -from typing import Callable - -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) - -from mastodon_manual_test import post_exceed_500_chars_limit_with_adoption_link -from social_posters.mastodon import PosterMastodon - """ How to use this to see each stage of the pipeline: see pet information: @@ -28,21 +15,34 @@ Extend sections to be previewed by modifying the PreviewStage class and add an action to sections """ +from __future__ import annotations + +import argparse +import os +import sys +from dataclasses import asdict, is_dataclass +from enum import StrEnum +from pprint import pprint + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from mastodon_manual_test import post_exceed_500_chars_limit_with_adoption_link +from social_posters.mastodon import CaptionThread, MastodonPhase, PosterMastodon +from utils.pipeline import Phase, PipelineResult +from utils.pipeline_preview import PreviewSection, print_section, render_sections + + class PreviewStage(StrEnum): PET = "pet" POST = "post" - DEBUG = "debug" + PREPARED_CAPTION = "prepared_caption" + CAPTION_THREAD = "caption_thread" MAIN = "main" REPLIES = "replies" + TRACE = "trace" ALL = "all" -def print_section(title: str) -> None: - print("\n" + "=" * 60) - print(title) - print("=" * 60) - - def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Preview each stage of the Mastodon formatting pipeline." @@ -57,68 +57,129 @@ def parse_args() -> argparse.Namespace: return parser.parse_args() -def includes_stage(selected: PreviewStage, target: PreviewStage) -> bool: - return selected in (target, PreviewStage.ALL) +def find_phase(pipeline: PipelineResult, phase_name: MastodonPhase) -> Phase | None: + for phase in pipeline.trace: + if phase.name == phase_name: + return phase + + return None + + +def value_for_phase(pipeline: PipelineResult, phase_name: MastodonPhase) -> object | None: + phase = find_phase(pipeline, phase_name) + return phase.value if phase else None + + +def print_value(value: object) -> None: + if is_dataclass(value) and not isinstance(value, type): + pprint(asdict(value)) + else: + pprint(value) + + +def print_phase(pipeline: PipelineResult, phase_name: MastodonPhase) -> None: + value = value_for_phase(pipeline, phase_name) + + if value is None: + print(f"(No value recorded for {phase_name})") + return + + print_value(value) + + +def print_trace(pipeline: PipelineResult) -> None: + for i, phase in enumerate(pipeline.trace, start=1): + print_section(f"PHASE {i}: {phase.name}") + print_value(phase.value) + + if pipeline.errors: + print_section("ERRORS") + for error in pipeline.errors: + print(f"{type(error).__name__}: {error}") + + +def print_main_caption(thread: CaptionThread | None) -> None: + if thread is None: + print("(No caption thread)") + return + + print(thread.main_caption) + print(f"\nLength: {len(thread.main_caption)}") + + +def print_replies(thread: CaptionThread | None) -> None: + if thread is None: + print("(No caption thread)") + return + + if not thread.replies: + print("(No replies)") + return + + for i, reply in enumerate(thread.replies, start=1): + print_section(f"REPLY {i}") + print(reply) + print(f"\nLength: {len(reply)}") def main() -> None: args = parse_args() - stage: PreviewStage = args.stage + selected_stage: PreviewStage = args.stage poster = PosterMastodon.__new__(PosterMastodon) pet = post_exceed_500_chars_limit_with_adoption_link() - post = poster.format_post(pet) + pipeline = poster.build_formatting_pipeline(pet) - main_caption, replies, trace = poster._format_caption_thread_with_trace(post) + thread = ( + pipeline.value + if pipeline.ok and isinstance(pipeline.value, CaptionThread) + else None + ) - sections: list[tuple[PreviewStage, str, Callable[[], None]]] = [ - ( + sections = [ + PreviewSection( PreviewStage.PET, "PET", - lambda: pprint(pet), + lambda: print_phase(pipeline, MastodonPhase.PET), ), - ( + PreviewSection( PreviewStage.POST, "POST OBJECT", - lambda: pprint(post), + lambda: print_phase(pipeline, MastodonPhase.POST), + ), + PreviewSection( + PreviewStage.PREPARED_CAPTION, + "PREPARED CAPTION", + lambda: print_phase(pipeline, MastodonPhase.PREPARED_CAPTION), ), - ( - PreviewStage.DEBUG, - "DEBUG PIPELINE", - lambda: pprint(asdict(trace)), + PreviewSection( + PreviewStage.CAPTION_THREAD, + "CAPTION THREAD", + lambda: print_phase(pipeline, MastodonPhase.CAPTION_THREAD), ), - ( + PreviewSection( PreviewStage.MAIN, "MAIN POST", - lambda: print_main_caption(main_caption), + lambda: print_main_caption(thread), ), - ( + PreviewSection( PreviewStage.REPLIES, "REPLIES", - lambda: print_replies(replies), + lambda: print_replies(thread), + ), + PreviewSection( + PreviewStage.TRACE, + "FULL TRACE", + lambda: print_trace(pipeline), ), ] - for target_stage, title, renderer in sections: - if includes_stage(stage, target_stage): - print_section(title) - renderer() - - -def print_main_caption(main_caption: str) -> None: - print(main_caption) - print(f"\nLength: {len(main_caption)}") - - -def print_replies(replies: list[str]) -> None: - if not replies: - print("(No replies)") - - for i, reply in enumerate(replies, start=1): - print_section(f"REPLY {i}") - print(reply) - print(f"\nLength: {len(reply)}") + render_sections( + sections=sections, + selected=selected_stage, + all_stage=PreviewStage.ALL, + ) if __name__ == "__main__": diff --git a/social_posters/mastodon.py b/social_posters/mastodon.py index 70a4ab3..db45d40 100644 --- a/social_posters/mastodon.py +++ b/social_posters/mastodon.py @@ -1,6 +1,9 @@ +from __future__ import annotations + import os import tempfile -from dataclasses import dataclass, field +from dataclasses import dataclass +from enum import StrEnum from urllib.parse import urlparse import requests @@ -8,52 +11,39 @@ from abstractions import AdoptablePet, Post, PostResult, SocialPoster from abstractions import CITY_NAME, CITY_STATE +from utils.pipeline import PipelineResult, add_phase, start_pipeline + THREAD_SUFFIX = "\n\nMore details below ⬇️" MASTODON_CHARACTER_LIMIT = 500 TRUNCATION_SUFFIX = "..." MAX_REPLIES = 5 -""" -Mastodon implementation of the Cute Pets Boston Project -Requires: MASTODON_TOKEN for authentication - -The domain that hosts our account (Mastodon.social) has -a 500 chars limit, we prioritize the adoption link at -the top of the post by overriding format_post, then -split the exceeding chars into the replies section -with number of replies capped at the MAX_REPLIES. If post -content does not exceed limit, no replies generated. If -replies exceed MAX_REPLIES, truncate it with `...` . Replies -are text-only and no media attached. - -Use the preview file within manual_testing folder to inspect -each phase of the pipeline when developing or debugging -Mastodon due to its complexity from split text. There, -you can choose to inspect only the pet, the formatted -post, the main post, the replies, or the trace itself that -contains the properties of the post. Use safe truncation to -prevent cutting off words when splitting text. Use -_format_caption_thread_with_trace to create split text with -trace. - -Pipeline: AdoptablePet => format_post (override formatting) => -_format_caption_thread_with_trace(Mastodon splitting) => -publish main status => publish replies (if needed) => PostResult -""" -@dataclass -class MastodonFormatTrace: - raw_text: str + +class MastodonPhase(StrEnum): + PET = "pet" + POST = "post" + PREPARED_CAPTION = "prepared_caption" + CAPTION_THREAD = "caption_thread" + + +@dataclass(frozen=True) +class PreparedCaption: + post: Post caption_text: str tags: list[str] tag_suffix: str - main_limit: int | None = None - main_text: str | None = None - overflow: str | None = None - main_caption: str | None = None - replies: list[str] = field(default_factory=list) - was_split: bool = False - was_capped: bool = False + + +@dataclass(frozen=True) +class CaptionThread: + main_caption: str + replies: list[str] + main_limit: int | None + main_text: str | None + overflow: str | None + was_split: bool + was_capped: bool class PosterMastodon(SocialPoster): @@ -161,65 +151,105 @@ def _post_thread( return status + def build_formatting_pipeline( + self, + pet: AdoptablePet, + ) -> PipelineResult[CaptionThread]: + pipeline = start_pipeline(MastodonPhase.PET, pet) + + pipeline = add_phase( + pipeline, + MastodonPhase.POST, + self.format_post, + ) + + pipeline = add_phase( + pipeline, + MastodonPhase.PREPARED_CAPTION, + self._prepare_caption, + ) + + pipeline = add_phase( + pipeline, + MastodonPhase.CAPTION_THREAD, + self._build_caption_thread, + ) + + return pipeline + def _format_caption_thread_with_trace( self, post: Post, - ) -> tuple[str, list[str], MastodonFormatTrace]: - caption_text, tag_suffix, trace = self._prepare_caption(post) - - if self._fits_single_post(caption_text, tag_suffix): - return self._build_single_post(caption_text, tag_suffix, trace) + ) -> tuple[str, list[str], PipelineResult[CaptionThread]]: + pipeline = start_pipeline(MastodonPhase.POST, post) - main_limit = self._validated_main_limit(tag_suffix) - main_text, overflow = self._safe_truncate(caption_text, main_limit) - replies = self._split_reply_chunks(overflow) - main_caption = self._build_main_caption(main_text, tag_suffix) + pipeline = add_phase( + pipeline, + MastodonPhase.PREPARED_CAPTION, + self._prepare_caption, + ) - trace = self._finalize_trace( - trace=trace, - main_limit=main_limit, - main_text=main_text, - overflow=overflow, - main_caption=main_caption, - replies=replies, + pipeline = add_phase( + pipeline, + MastodonPhase.CAPTION_THREAD, + self._build_caption_thread, ) - return main_caption, replies, trace + if not pipeline.ok or pipeline.value is None: + error = pipeline.errors[0] if pipeline.errors else RuntimeError("Unknown error") + raise error + + return pipeline.value.main_caption, pipeline.value.replies, pipeline def _format_caption_thread(self, post: Post) -> tuple[str, list[str]]: main_caption, replies, _ = self._format_caption_thread_with_trace(post) return main_caption, replies - def _prepare_caption( - self, - post: Post, - ) -> tuple[str, str, MastodonFormatTrace]: + def _prepare_caption(self, post: Post) -> PreparedCaption: tags, tag_suffix = self._format_tags(post.tags) - caption_text = post.text.strip() - trace = MastodonFormatTrace( - raw_text=post.text, - caption_text=caption_text, + return PreparedCaption( + post=post, + caption_text=post.text.strip(), tags=tags, tag_suffix=tag_suffix, ) - return caption_text, tag_suffix, trace + def _build_caption_thread(self, prepared: PreparedCaption) -> CaptionThread: + caption_text = prepared.caption_text + tag_suffix = prepared.tag_suffix + + if self._fits_single_post(caption_text, tag_suffix): + return CaptionThread( + main_caption=f"{caption_text}{tag_suffix}", + replies=[], + main_limit=None, + main_text=caption_text, + overflow="", + was_split=False, + was_capped=False, + ) + + main_limit = self._validated_main_limit(tag_suffix) + main_text, overflow = self._safe_truncate(caption_text, main_limit) + replies = self._split_reply_chunks(overflow) + + main_caption = self._build_main_caption(main_text, tag_suffix) + + return CaptionThread( + main_caption=main_caption, + replies=replies, + main_limit=main_limit, + main_text=main_text, + overflow=overflow, + was_split=True, + was_capped=replies[-1].endswith(TRUNCATION_SUFFIX) if replies else False, + ) @staticmethod def _fits_single_post(caption_text: str, tag_suffix: str) -> bool: return len(caption_text) + len(tag_suffix) <= MASTODON_CHARACTER_LIMIT - @staticmethod - def _build_single_post( - caption_text: str, - tag_suffix: str, - trace: MastodonFormatTrace, - ) -> tuple[str, list[str], MastodonFormatTrace]: - main_caption = f"{caption_text}{tag_suffix}" - trace.main_caption = main_caption - return main_caption, [], trace - def _validated_main_limit(self, tag_suffix: str) -> int: main_limit = self._main_caption_limit(tag_suffix) @@ -237,29 +267,6 @@ def _build_main_caption(main_text: str, tag_suffix: str) -> str: f"{tag_suffix}" ) - @staticmethod - def _finalize_trace( - trace: MastodonFormatTrace, - main_limit: int, - main_text: str, - overflow: str, - main_caption: str, - replies: list[str], - ) -> MastodonFormatTrace: - trace.main_limit = main_limit - trace.main_text = main_text - trace.overflow = overflow - trace.replies = replies - trace.main_caption = main_caption - trace.was_split = True - trace.was_capped = ( - replies[-1].endswith(TRUNCATION_SUFFIX) - if replies - else False - ) - - return trace - @staticmethod def _format_tags(tags: list[str]) -> tuple[list[str], str]: clean_tags = [tag for tag in tags if tag] diff --git a/tests/test_mastodon.py b/tests/test_mastodon.py index 19a5e0f..3c40960 100644 --- a/tests/test_mastodon.py +++ b/tests/test_mastodon.py @@ -1,7 +1,3 @@ -from abstractions import Post -from social_posters.mastodon import PosterMastodon, MASTODON_CHARACTER_LIMIT, MAX_REPLIES -from hypothesis import given, strategies as st - """ Testing includes: 1. Property-based testing (Hypothesis) @@ -32,22 +28,33 @@ - See Preview file for details """ -# Generation rules for tags/texts used in testing +from abstractions import Post +from hypothesis import given, strategies as st +from social_posters.mastodon import ( + CaptionThread, + MastodonPhase, + PosterMastodon, + MASTODON_CHARACTER_LIMIT, + MAX_REPLIES, +) + + tag_strategy = st.lists( st.one_of(st.text(min_size=0, max_size=20), st.none()), - max_size=10 + max_size=10, ) text_strategy = st.text( alphabet=st.characters(blacklist_categories=("Cs",)), min_size=0, - max_size=5000 + max_size=5000, ) + class TestMastodonCaptionProperties: def setup_method(self): self.poster = PosterMastodon.__new__(PosterMastodon) - + @given(text=text_strategy, tags=tag_strategy) def test_all_parts_stay_under_mastodon_limit(self, text, tags): post = Post(text=text, tags=tags) @@ -74,28 +81,35 @@ def test_no_empty_replies(self, text): assert all(reply for reply in replies) @given(text=text_strategy, tags=tag_strategy) - def test_debug_matches_normal_formatter(self, text, tags): + def test_trace_matches_regular_formatter(self, text, tags): post = Post(text=text, tags=tags) main_caption, replies = self.poster._format_caption_thread(post) - debug_main, debug_replies, debug = self.poster._format_caption_thread_with_trace(post) + trace_main, trace_replies, pipeline = ( + self.poster._format_caption_thread_with_trace(post) + ) + + assert pipeline.ok + assert isinstance(pipeline.value, CaptionThread) - assert debug_main == main_caption - assert debug_replies == replies - assert debug.main_caption == main_caption - assert debug.replies == replies + assert trace_main == main_caption + assert trace_replies == replies + assert pipeline.value.main_caption == main_caption + assert pipeline.value.replies == replies @given(text=text_strategy, tags=tag_strategy) - def test_trace_matches_regular_formatter(self, text, tags): + def test_trace_records_expected_phases(self, text, tags): post = Post(text=text, tags=tags) - main_caption, replies = self.poster._format_caption_thread(post) - trace_main, trace_replies, trace = self.poster._format_caption_thread_with_trace(post) + _, _, pipeline = self.poster._format_caption_thread_with_trace(post) - assert trace_main == main_caption - assert trace_replies == replies - assert trace.main_caption == main_caption - assert trace.replies == replies + phase_names = [phase.name for phase in pipeline.trace] + + assert phase_names == [ + MastodonPhase.POST, + MastodonPhase.PREPARED_CAPTION, + MastodonPhase.CAPTION_THREAD, + ] class TestMastodonCaption: @@ -128,7 +142,7 @@ def test_last_reply_has_truncation_suffix_when_capped(self): assert len(replies) == MAX_REPLIES assert replies[-1].endswith("...") assert len(replies[-1]) <= MASTODON_CHARACTER_LIMIT - + def test_last_reply_has_no_truncation_suffix_when_not_capped(self): post = Post(text="hello " * 300) @@ -149,7 +163,7 @@ def test_capped_thread_does_not_preserve_all_original_text(self): assert len(replies) == MAX_REPLIES assert reconstructed != original_text assert replies[-1].endswith("...") - + def test_thread_preserves_original_text_content(self): original_text = " ".join(f"word{i}" for i in range(300)) post = Post(text=original_text, tags=["AdoptDontShop", "Boston"]) @@ -180,12 +194,15 @@ def test_thread_preserves_original_text_without_spaces(self): def test_no_tags(self): post = Post(text="Hello, world!") - main_caption, replies = self.poster._format_caption_thread(post) + + main_caption, replies = self.poster._format_caption_thread(post) + assert main_caption == "Hello, world!" assert replies == [] def test_with_tags(self): post = Post(text="Meet Poppy!", tags=["AdoptDontShop", "Boston"]) + main_caption, replies = self.poster._format_caption_thread(post) assert main_caption == "Meet Poppy!\n\n#AdoptDontShop #Boston" @@ -193,6 +210,7 @@ def test_with_tags(self): def test_caption_stays_under_limit_and_creates_reply(self): post = Post(text="x " * 1000, tags=["AdoptDontShop", "Boston"]) + main_caption, replies = self.poster._format_caption_thread(post) assert len(main_caption) <= MASTODON_CHARACTER_LIMIT @@ -204,6 +222,7 @@ def test_caption_stays_under_limit_and_creates_reply(self): def test_caption_stays_under_limit_creates_reply(self): post = Post(text="x" * 1000, tags=["AdoptDontShop", "Boston"]) + main_caption, replies = self.poster._format_caption_thread(post) assert len(main_caption) <= MASTODON_CHARACTER_LIMIT @@ -215,20 +234,23 @@ def test_caption_stays_under_limit_creates_reply(self): def test_empty_tags_are_ignored(self): post = Post(text="Meet Poppy!", tags=["AdoptDontShop", "", None, "Boston"]) + main_caption, replies = self.poster._format_caption_thread(post) assert main_caption == "Meet Poppy!\n\n#AdoptDontShop #Boston" assert replies == [] - + def test_long_text_without_tags_creates_replies(self): post = Post(text="hello " * 300) + main_caption, replies = self.poster._format_caption_thread(post) assert len(main_caption) <= MASTODON_CHARACTER_LIMIT assert replies assert all(len(reply) <= MASTODON_CHARACTER_LIMIT for reply in replies) - + def test_safe_truncate_does_not_split_words_when_possible(self): kept, remaining = self.poster._safe_truncate("hello world again", 12) + assert kept == "hello world" assert remaining == "again" \ No newline at end of file diff --git a/utils/pipeline.py b/utils/pipeline.py new file mode 100644 index 0000000..0721f43 --- /dev/null +++ b/utils/pipeline.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Callable, Generic, TypeVar + + +T = TypeVar("T") +U = TypeVar("U") + + +@dataclass(frozen=True) +class Phase: + name: StrEnum + value: object + + +@dataclass +class PipelineResult(Generic[T]): + value: T | None + trace: list[Phase] = field(default_factory=list) + errors: list[Exception] = field(default_factory=list) + + @property + def ok(self) -> bool: + return not self.errors + + +def start_pipeline(name: StrEnum, value: T) -> PipelineResult[T]: + return PipelineResult( + value=value, + trace=[Phase(name, value)], + ) + + +def add_phase( + pipeline: PipelineResult[T], + name: StrEnum, + fn: Callable[[T], U], +) -> PipelineResult[U]: + if not pipeline.ok: + return PipelineResult( + value=None, + trace=pipeline.trace, + errors=pipeline.errors, + ) + + try: + assert pipeline.value is not None + new_value = fn(pipeline.value) + return PipelineResult( + value=new_value, + trace=pipeline.trace + [Phase(name, new_value)], + ) + except Exception as exc: + return PipelineResult( + value=None, + trace=pipeline.trace, + errors=pipeline.errors + [exc], + ) \ No newline at end of file diff --git a/utils/pipeline_preview.py b/utils/pipeline_preview.py new file mode 100644 index 0000000..36d25fb --- /dev/null +++ b/utils/pipeline_preview.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum +from typing import Callable + + +@dataclass(frozen=True) +class PreviewSection: + stage: StrEnum + title: str + render: Callable[[], None] + + +def print_section(title: str) -> None: + print("\n" + "=" * 60) + print(title) + print("=" * 60) + + +def includes_stage(selected: StrEnum, target: StrEnum, all_stage: StrEnum) -> bool: + return selected in (target, all_stage) + + +def render_sections( + sections: list[PreviewSection], + selected: StrEnum, + all_stage: StrEnum, +) -> None: + for section in sections: + if includes_stage(selected, section.stage, all_stage): + print_section(section.title) + section.render() \ No newline at end of file From 24f9b4f5faefcfc7ba139d373ae77974234ef980 Mon Sep 17 00:00:00 2001 From: patrickZWY <154947644+patrickZWY@users.noreply.github.com> Date: Sun, 17 May 2026 12:31:26 -0700 Subject: [PATCH 09/12] add documentation --- social_posters/mastodon.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/social_posters/mastodon.py b/social_posters/mastodon.py index db45d40..428d638 100644 --- a/social_posters/mastodon.py +++ b/social_posters/mastodon.py @@ -151,6 +151,12 @@ def _post_thread( return status + """ + Implement your own pipeline and consume it in a preview file + for debugging purposes + Here, we build a pipeline from AdoptablePet (upstream input) + -> Post -> Prepared Caption -> Caption Thread + """ def build_formatting_pipeline( self, pet: AdoptablePet, @@ -177,6 +183,10 @@ def build_formatting_pipeline( return pipeline + """ + Pipeline for publishing a post where trace thrown away when actually publishing + + """ def _format_caption_thread_with_trace( self, post: Post, From 72c8a55cff614a9186c83377236f1658f8cf8aac Mon Sep 17 00:00:00 2001 From: patrickZWY <154947644+patrickZWY@users.noreply.github.com> Date: Sun, 17 May 2026 17:34:08 -0700 Subject: [PATCH 10/12] remove unnecessary trace method and fix test --- social_posters/mastodon.py | 33 +---------- tests/test_mastodon.py | 117 ++++++++++++++++++++++++++++--------- 2 files changed, 94 insertions(+), 56 deletions(-) diff --git a/social_posters/mastodon.py b/social_posters/mastodon.py index 428d638..e0ff67d 100644 --- a/social_posters/mastodon.py +++ b/social_posters/mastodon.py @@ -183,37 +183,10 @@ def build_formatting_pipeline( return pipeline - """ - Pipeline for publishing a post where trace thrown away when actually publishing - - """ - def _format_caption_thread_with_trace( - self, - post: Post, - ) -> tuple[str, list[str], PipelineResult[CaptionThread]]: - pipeline = start_pipeline(MastodonPhase.POST, post) - - pipeline = add_phase( - pipeline, - MastodonPhase.PREPARED_CAPTION, - self._prepare_caption, - ) - - pipeline = add_phase( - pipeline, - MastodonPhase.CAPTION_THREAD, - self._build_caption_thread, - ) - - if not pipeline.ok or pipeline.value is None: - error = pipeline.errors[0] if pipeline.errors else RuntimeError("Unknown error") - raise error - - return pipeline.value.main_caption, pipeline.value.replies, pipeline - def _format_caption_thread(self, post: Post) -> tuple[str, list[str]]: - main_caption, replies, _ = self._format_caption_thread_with_trace(post) - return main_caption, replies + prepared = self._prepare_caption(post) + thread = self._build_caption_thread(prepared) + return thread.main_caption, thread.replies def _prepare_caption(self, post: Post) -> PreparedCaption: tags, tag_suffix = self._format_tags(post.tags) diff --git a/tests/test_mastodon.py b/tests/test_mastodon.py index 3c40960..f538930 100644 --- a/tests/test_mastodon.py +++ b/tests/test_mastodon.py @@ -10,7 +10,7 @@ - Verify global invariants such as: * captions never exceed Mastodon limits * replies never exceed MAX_REPLIES - * formatter trace matches normal formatter + * full formatting pipeline matches runtime formatter * no empty replies are produced Unit tests: @@ -28,7 +28,7 @@ - See Preview file for details """ -from abstractions import Post +from abstractions import Post, AdoptablePet from hypothesis import given, strategies as st from social_posters.mastodon import ( CaptionThread, @@ -50,6 +50,75 @@ max_size=5000, ) +pet_strategy = st.builds( + AdoptablePet, + name=st.text( + alphabet=st.characters(blacklist_categories=("Cs",)), + min_size=1, + max_size=20, + ), + species=st.sampled_from( + [ + "Dog", + "Cat", + "Rabbit", + "Bird", + "Alien", + "Unknown", + ] + ), + breed=st.text( + alphabet=st.characters(blacklist_categories=("Cs",)), + min_size=1, + max_size=30, + ), + location=st.text( + alphabet=st.characters(blacklist_categories=("Cs",)), + min_size=1, + max_size=50, + ), + description=text_strategy, + adoption_url=st.one_of( + st.none(), + st.just("https://example.com/adopt"), + ), + image_url=st.just("https://example.com/image.jpg"), + age_string=st.one_of( + st.none(), + st.text( + alphabet=st.characters(blacklist_categories=("Cs",)), + min_size=1, + max_size=20, + ), + ), + sex=st.one_of( + st.none(), + st.sampled_from(["Male", "Female", "Unknown"]), + ), + size_group=st.one_of( + st.none(), + st.sampled_from(["Small", "Medium", "Large"]), + ), + pet_id=st.one_of( + st.none(), + st.text( + alphabet=st.characters(blacklist_categories=("Cs",)), + min_size=1, + max_size=20, + ), + ), +) + +def reconstruct_text(main_caption: str, replies: list[str]) -> str: + main_without_tags = main_caption.split("\n\n#")[0] + main_without_suffix = ( + main_without_tags + .replace("...", "") + .replace("\n\nMore details below ⬇️", "") + .strip() + ) + + return " ".join([main_without_suffix] + replies).strip() class TestMastodonCaptionProperties: def setup_method(self): @@ -81,31 +150,36 @@ def test_no_empty_replies(self, text): assert all(reply for reply in replies) @given(text=text_strategy, tags=tag_strategy) - def test_trace_matches_regular_formatter(self, text, tags): + def test_reconstruction_preserves_uncapped_threads(self, text, tags): post = Post(text=text, tags=tags) main_caption, replies = self.poster._format_caption_thread(post) - trace_main, trace_replies, pipeline = ( - self.poster._format_caption_thread_with_trace(post) - ) + + if len(replies) < MAX_REPLIES: + reconstructed = reconstruct_text(main_caption, replies) + assert reconstructed == text.strip() + + @given(pet=pet_strategy) + def test_pipeline_matches_regular_formatter(self, pet): + post = self.poster.format_post(pet) + + main_caption, replies = self.poster._format_caption_thread(post) + pipeline = self.poster.build_formatting_pipeline(pet) assert pipeline.ok assert isinstance(pipeline.value, CaptionThread) - assert trace_main == main_caption - assert trace_replies == replies assert pipeline.value.main_caption == main_caption assert pipeline.value.replies == replies - @given(text=text_strategy, tags=tag_strategy) - def test_trace_records_expected_phases(self, text, tags): - post = Post(text=text, tags=tags) - - _, _, pipeline = self.poster._format_caption_thread_with_trace(post) + @given(pet=pet_strategy) + def test_pipeline_records_expected_phases(self, pet): + pipeline = self.poster.build_formatting_pipeline(pet) phase_names = [phase.name for phase in pipeline.trace] assert phase_names == [ + MastodonPhase.PET, MastodonPhase.POST, MastodonPhase.PREPARED_CAPTION, MastodonPhase.CAPTION_THREAD, @@ -116,16 +190,7 @@ class TestMastodonCaption: def setup_method(self): self.poster = PosterMastodon.__new__(PosterMastodon) - def reconstruct_text(self, main_caption: str, replies: list[str]) -> str: - main_without_tags = main_caption.split("\n\n#")[0] - main_without_suffix = ( - main_without_tags - .replace("...", "") - .replace("\n\nMore details below ⬇️", "") - .strip() - ) - return " ".join([main_without_suffix] + replies).strip() def test_reply_count_is_capped(self): post = Post(text="hello " * 5000) @@ -158,7 +223,7 @@ def test_capped_thread_does_not_preserve_all_original_text(self): main_caption, replies = self.poster._format_caption_thread(post) - reconstructed = self.reconstruct_text(main_caption, replies) + reconstructed = reconstruct_text(main_caption, replies) assert len(replies) == MAX_REPLIES assert reconstructed != original_text @@ -170,7 +235,7 @@ def test_thread_preserves_original_text_content(self): main_caption, replies = self.poster._format_caption_thread(post) - reconstructed = self.reconstruct_text(main_caption, replies) + reconstructed = reconstruct_text(main_caption, replies) assert reconstructed == original_text @@ -208,7 +273,7 @@ def test_with_tags(self): assert main_caption == "Meet Poppy!\n\n#AdoptDontShop #Boston" assert replies == [] - def test_caption_stays_under_limit_and_creates_reply(self): + def test_spaced_long_text_creates_reply(self): post = Post(text="x " * 1000, tags=["AdoptDontShop", "Boston"]) main_caption, replies = self.poster._format_caption_thread(post) @@ -220,7 +285,7 @@ def test_caption_stays_under_limit_and_creates_reply(self): assert replies assert all(len(reply) <= MASTODON_CHARACTER_LIMIT for reply in replies) - def test_caption_stays_under_limit_creates_reply(self): + def test_unspaced_long_text_creates_reply(self): post = Post(text="x" * 1000, tags=["AdoptDontShop", "Boston"]) main_caption, replies = self.poster._format_caption_thread(post) From e74f4d65de5c8e5454ce5ec12007bc2578bc7735 Mon Sep 17 00:00:00 2001 From: patrickZWY <154947644+patrickZWY@users.noreply.github.com> Date: Sun, 17 May 2026 17:52:34 -0700 Subject: [PATCH 11/12] documentation --- manual_testing/mastodon_preview_test.py | 41 +++++++++++++++++++------ utils/pipeline.py | 15 +++++++++ 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/manual_testing/mastodon_preview_test.py b/manual_testing/mastodon_preview_test.py index c71e127..c919ed0 100644 --- a/manual_testing/mastodon_preview_test.py +++ b/manual_testing/mastodon_preview_test.py @@ -1,20 +1,41 @@ """ -How to use this to see each stage of the pipeline: -see pet information: +Mastodon formatting preview tool: + +This file visualizes different stages of the Mastodon formatting pipeline. + +Pipeline structure: +AdoptablePet + -> Post + -> PreparedCaption + -> CaptionThread + +Examples: + +Preview raw pet input: python manual_tests/mastodon_preview.py --stage pet -see post information (platform ready but not mastodon processed): + +Preview generated platform-independent Post: python manual_tests/mastodon_preview.py --stage post -see full formatting in mastodon: + +Preview fully formatted Mastodon thread: python manual_tests/mastodon_preview.py --stage debug -see only main thread part of the formatting in mastodon: + +Preview only the main Mastodon post: python manual_tests/mastodon_preview.py --stage main -see only replies part of the formatting in mastodon: + +Preview only reply thread chunks: python manual_tests/mastodon_preview.py --stage replies -see all stages: -python manual_tests/mastodon_preview.py --stage all (or no arg default to all) -Extend sections to be previewed by modifying the PreviewStage class and add -an action to sections + +Preview every pipeline stage: +python manual_tests/mastodon_preview.py --stage all + +(default behavior is --stage all) + +To extend preview stages: +1. Add a new PreviewStage enum entry +2. Add a renderer/action for that stage """ + from __future__ import annotations import argparse diff --git a/utils/pipeline.py b/utils/pipeline.py index 0721f43..3735f33 100644 --- a/utils/pipeline.py +++ b/utils/pipeline.py @@ -1,3 +1,18 @@ +""" +A generic pipeline utility for building preview/debug traces. + +Each phase transforms the current pipeline value from one type to another: + + PipelineResult[T] + Callable[[T], U] -> PipelineResult[U] + +For example: + + AdoptablePet -> Post -> PreparedCaption -> CaptionThread + +The trace stores every intermediate phase value for preview/debug output, +while `value` always represents the latest pipeline result. +""" + from __future__ import annotations from dataclasses import dataclass, field From c98e24beee86c93d5082364e9e6743a5327722fb Mon Sep 17 00:00:00 2001 From: patrickZWY <154947644+patrickZWY@users.noreply.github.com> Date: Mon, 18 May 2026 10:37:41 -0700 Subject: [PATCH 12/12] rename and comment --- utils/pipeline_preview.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/utils/pipeline_preview.py b/utils/pipeline_preview.py index 36d25fb..aacd15d 100644 --- a/utils/pipeline_preview.py +++ b/utils/pipeline_preview.py @@ -1,3 +1,9 @@ +""" +Pipeline preview renderer. + +Provides utilities for rendering selected stages of a pipeline +for debugging and inspection. +""" from __future__ import annotations from dataclasses import dataclass @@ -18,7 +24,7 @@ def print_section(title: str) -> None: print("=" * 60) -def includes_stage(selected: StrEnum, target: StrEnum, all_stage: StrEnum) -> bool: +def should_render_section(selected: StrEnum, target: StrEnum, all_stage: StrEnum) -> bool: return selected in (target, all_stage) @@ -28,6 +34,6 @@ def render_sections( all_stage: StrEnum, ) -> None: for section in sections: - if includes_stage(selected, section.stage, all_stage): + if should_render_section(selected, section.stage, all_stage): print_section(section.title) section.render() \ No newline at end of file