Skip to content

Commit d05941d

Browse files
committed
Merge branch 'master' into filter-by-shelter
2 parents 6452cb6 + fda08f6 commit d05941d

6 files changed

Lines changed: 456 additions & 58 deletions

File tree

adoption_sources/rescue_groups.py

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
from typing import Iterator
1313

1414
import requests
15+
from requests.adapters import HTTPAdapter
16+
from urllib3.util.retry import Retry
1517

1618
from abstractions import AdoptablePet, PetSource
1719
from config import CITY_NAME, CITY_STATE, POSTAL_CODE
@@ -22,6 +24,27 @@
2224
# website; those should never be posted. Add new names here as we encounter them.
2325
PLACEHOLDER_NAMES: tuple[str, ...] = ("more dogs soon!",)
2426

27+
# The RescueGroups API occasionally times out or returns a transient 5xx. A
28+
# single hiccup shouldn't fail the whole run, so retry a few times with
29+
# exponential backoff (0s, 2s, 4s, 8s between attempts).
30+
RETRY_TOTAL = 4
31+
RETRY_BACKOFF_FACTOR = 1
32+
33+
34+
def _session_with_retries() -> requests.Session:
35+
"""Build a requests Session that retries transient errors with backoff."""
36+
retry = Retry(
37+
total=RETRY_TOTAL,
38+
backoff_factor=RETRY_BACKOFF_FACTOR,
39+
status_forcelist=(429, 500, 502, 503, 504),
40+
# We only POST, so POST must be opted in (it isn't retried by default).
41+
allowed_methods=frozenset({"POST"}),
42+
raise_on_status=False,
43+
)
44+
session = requests.Session()
45+
session.mount("https://", HTTPAdapter(max_retries=retry))
46+
return session
47+
2548

2649
class SourceRescueGroups(PetSource):
2750
"""
@@ -68,7 +91,7 @@ def fetch_pets(self) -> Iterator[AdoptablePet]:
6891
"RescueGroups API key not configured. "
6992
"Set CUTEPETSBOSTON_RESCUEGROUPS_API_KEY environment variable."
7093
)
71-
94+
7295
url = (
7396
f"{self.BASE_URL}"
7497
# f"?include=orgs,breeds,locations"
@@ -88,12 +111,12 @@ def fetch_pets(self) -> Iterator[AdoptablePet]:
88111
# }
89112
}
90113

91-
92114
logger.info(
93115
f"Fetching {self.species} from RescueGroups within {self.radius_miles} miles of {self.postal_code}"
94116
)
95117

96-
response = requests.get(url, headers=headers, timeout=30)
118+
session = _session_with_retries()
119+
response = session.post(url, json=payload, headers=headers, timeout=30)
97120
response.raise_for_status()
98121

99122
body = response.json()
@@ -144,9 +167,18 @@ def _parse_animal(self, animal: dict, orgs_by_id: dict) -> AdoptablePet | None:
144167
)
145168
org_attrs = orgs_by_id.get(org_id, {}) if org_id else {}
146169
adoption_url = next(
147-
(u for u in (attrs.get("adoptionUrl"), org_attrs.get("adoptionUrl"), org_attrs.get("url"))
148-
if u and u.strip().rstrip("/") not in ("http:", "https:", "http://", "https://")),
149-
None
170+
(
171+
u
172+
for u in (
173+
attrs.get("adoptionUrl"),
174+
org_attrs.get("adoptionUrl"),
175+
org_attrs.get("url"),
176+
)
177+
if u
178+
and u.strip().rstrip("/")
179+
not in ("http:", "https:", "http://", "https://")
180+
),
181+
None,
150182
)
151183

152184
# Get best available image
@@ -155,7 +187,6 @@ def _parse_animal(self, animal: dict, orgs_by_id: dict) -> AdoptablePet | None:
155187
# Location of the adoption org
156188
location = f"{org_attrs.get('city')}, {org_attrs.get('state')}"
157189

158-
159190
return AdoptablePet(
160191
name=name,
161192
species=species,

docs/CNAME

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
www.cutepetsboston.com

manual_testing/mastodon_manual_test.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ def post_exceed_500_chars_limit_with_adoption_link():
1010
"Labrador Retriever",
1111
"White Labrador",
1212
"Quahog",
13-
"I am a writer! Post exceeds limit with adoption link"*1000,
13+
"I am a writer! Post exceeds limit with adoption link"*200,
1414
"http://www.davidgorman.com/4quartets/",
1515
"https://static.wikia.nocookie.net/familyguy/images/c/c2/FamilyGuy_Single_BrianWriter_R7.jpg/revision/latest?cb=20230807152447",
1616
11,
@@ -83,10 +83,10 @@ def post_unicode():
8383

8484
testing_cases = [
8585
post_exceed_500_chars_limit_with_adoption_link,
86-
post_exceed_500_chars_limit_without_adoption_link,
87-
post_within_500_chars_limit_with_adoption_link,
88-
post_within_500_chars_limit_without_adoption_link,
89-
post_unicode
86+
#post_exceed_500_chars_limit_without_adoption_link,
87+
#post_within_500_chars_limit_with_adoption_link,
88+
#post_within_500_chars_limit_without_adoption_link,
89+
#post_unicode,
9090
]
9191

9292
def main():

requirements.txt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ certifi
77
chardet==3.0.4
88
charset-normalizer
99
clarifai==2.6.2
10+
click==8.3.3
1011
configparser==3.8.1
1112
decorator
1213
EasyProcess==1.1
@@ -17,14 +18,21 @@ grpcio==1.78.0
1718
h11==0.16.0
1819
httpcore==1.0.9
1920
httpx==0.28.1
21+
hypothesis==6.152.7
2022
idna==2.10
23+
iniconfig==2.3.0
2124
instapy==0.6.16
2225
jsonschema==2.6.0
2326
Mastodon.py
2427
MeaningCloud-python==2.0.0
2528
outcome==1.3.0.post0
29+
packaging==26.2
30+
pip-tools==7.5.3
31+
pluggy==1.6.0
2632
plyer==2.1.0
2733
protobuf==3.20.3
34+
Pygments==2.20.0
35+
pyproject_hooks==1.2.0
2836
PySocks==1.7.1
2937
certifi==2026.4.22
3038
charset-normalizer==3.4.7
@@ -33,6 +41,10 @@ idna
3341
Mastodon.py==2.2.1
3442
python-dateutil==2.9.0.post0
3543
requests==2.33.1
44+
setuptools==82.0.0
3645
six==1.17.0
46+
sortedcontainers==2.4.0
47+
typing_extensions==4.15.0
3748
urllib3==2.6.3
3849
pytest
50+
wheel==0.47.0

social_posters/mastodon.py

Lines changed: 126 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,30 @@
1+
from __future__ import annotations
2+
13
import os
2-
from urllib.parse import urlparse
34
import tempfile
5+
from urllib.parse import urlparse
46

57
import requests
68
from mastodon import Mastodon
79

8-
from abstractions import Post, PostResult, SocialPoster, AdoptablePet
10+
from abstractions import AdoptablePet, Post, PostResult, SocialPoster
911
from abstractions import CITY_NAME, CITY_STATE
1012

13+
14+
THREAD_SUFFIX = "\n\nMore details below ⬇️"
1115
MASTODON_CHARACTER_LIMIT = 500
1216
TRUNCATION_SUFFIX = "..."
17+
MAX_REPLIES = 5
1318

1419

1520
class PosterMastodon(SocialPoster):
16-
def __init__(self):
21+
def __init__(self) -> None:
1722
raw_token = os.environ.get("MASTODON_TOKEN")
1823
self.token = raw_token.strip() if raw_token else None
1924
self.api_base_url = "https://mastodon.social"
20-
self._session = None
25+
self._session: Mastodon | None = None
2126
self._is_available = bool(self.token)
22-
self._auth_error = None
27+
self._auth_error: str | None = None
2328

2429
@property
2530
def platform_name(self) -> str:
@@ -61,69 +66,151 @@ def publish(self, post: Post) -> PostResult:
6166
else f"Mastodon authentication failed: {self._auth_error}"
6267
),
6368
)
64-
69+
6570
image_path = None
71+
6672
try:
6773
image_path = self._download_image(post.image_url)
74+
6875
media = self._session.media_post(
6976
image_path,
7077
description=post.alt_text or "Photo of an adoptable pet",
7178
)
72-
status = self._session.status_post(
73-
self._format_caption(post),
74-
media_ids=[media["id"]],
75-
)
79+
80+
main_caption, replies = self._format_caption_thread(post)
81+
status = self._post_thread(main_caption, replies, media["id"])
82+
7683
return PostResult(
7784
success=True,
7885
post_id=str(status["id"]),
7986
post_url=status.get("url"),
8087
)
88+
8189
except Exception as exc:
8290
return PostResult(success=False, error_message=str(exc))
91+
8392
finally:
8493
self._session = None
8594
if image_path and os.path.exists(image_path):
8695
os.unlink(image_path)
8796

88-
def _format_caption(self, post: Post) -> str:
89-
tags = " ".join(f"#{tag}" for tag in post.tags if tag)
90-
tag_suffix = f"\n\n{tags}" if tags else ""
91-
available_text_length = MASTODON_CHARACTER_LIMIT - len(tag_suffix)
97+
def _post_thread(
98+
self,
99+
main_caption: str,
100+
replies: list[str],
101+
media_id: str,
102+
) -> dict:
103+
status = self._session.status_post(
104+
main_caption,
105+
media_ids=[media_id],
106+
)
107+
108+
root_status_id = status["id"]
109+
110+
for reply_text in replies:
111+
self._session.status_post(
112+
reply_text,
113+
in_reply_to_id=root_status_id,
114+
)
92115

93-
if available_text_length <= len(TRUNCATION_SUFFIX):
94-
return (tag_suffix[-MASTODON_CHARACTER_LIMIT:]).strip()
116+
return status
95117

118+
def _format_caption_thread(self, post: Post) -> tuple[str, list[str]]:
96119
caption_text = post.text.strip()
97-
if len(caption_text) > available_text_length:
98-
caption_text = caption_text[: available_text_length - len(TRUNCATION_SUFFIX)].rstrip()
99-
caption_text = f"{caption_text}{TRUNCATION_SUFFIX}"
120+
tag_suffix = self._format_tag_suffix(post.tags)
121+
122+
if self._fits_single_post(caption_text, tag_suffix):
123+
return f"{caption_text}{tag_suffix}", []
124+
125+
main_limit = self._main_caption_limit(tag_suffix)
126+
127+
if main_limit <= 0:
128+
raise ValueError("Tags are too long to fit in a Mastodon post.")
129+
130+
main_text, overflow = self._safe_truncate(caption_text, main_limit)
131+
replies = self._split_reply_chunks(overflow)
132+
133+
main_caption = (
134+
f"{main_text}"
135+
f"{TRUNCATION_SUFFIX}"
136+
f"{THREAD_SUFFIX}"
137+
f"{tag_suffix}"
138+
)
139+
140+
return main_caption, replies
141+
142+
@staticmethod
143+
def _fits_single_post(caption_text: str, tag_suffix: str) -> bool:
144+
return len(caption_text) + len(tag_suffix) <= MASTODON_CHARACTER_LIMIT
145+
146+
@staticmethod
147+
def _format_tag_suffix(tags: list[str]) -> str:
148+
clean_tags = [tag for tag in tags if tag]
149+
tag_text = " ".join(f"#{tag}" for tag in clean_tags)
150+
return f"\n\n{tag_text}" if tag_text else ""
151+
152+
@staticmethod
153+
def _main_caption_limit(tag_suffix: str) -> int:
154+
return (
155+
MASTODON_CHARACTER_LIMIT
156+
- len(tag_suffix)
157+
- len(THREAD_SUFFIX)
158+
- len(TRUNCATION_SUFFIX)
159+
)
160+
161+
def _split_reply_chunks(self, text: str) -> list[str]:
162+
chunks = []
163+
remaining = text.strip()
164+
165+
while remaining and len(chunks) < MAX_REPLIES:
166+
chunk, remaining = self._safe_truncate(
167+
remaining,
168+
MASTODON_CHARACTER_LIMIT,
169+
)
170+
chunks.append(chunk)
100171

101-
return f"{caption_text}{tag_suffix}"
172+
if remaining and chunks:
173+
cutoff = MASTODON_CHARACTER_LIMIT - len(TRUNCATION_SUFFIX)
174+
last_chunk, _ = self._safe_truncate(chunks[-1], cutoff)
175+
chunks[-1] = f"{last_chunk}{TRUNCATION_SUFFIX}"
176+
177+
return chunks
102178

103179
def _download_image(self, image_url: str) -> str:
104180
parsed_url = urlparse(image_url)
105181
ext = os.path.splitext(parsed_url.path)[1] or ".jpg"
182+
106183
with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp:
107-
response = requests.get(image_url, stream=True, timeout=20)
108-
response.raise_for_status()
109-
for chunk in response.iter_content(chunk_size=1024 * 128):
110-
if chunk:
111-
tmp.write(chunk)
184+
with requests.get(image_url, stream=True, timeout=20) as response:
185+
response.raise_for_status()
186+
187+
for chunk in response.iter_content(chunk_size=1024 * 128):
188+
if chunk:
189+
tmp.write(chunk)
190+
112191
return tmp.name
113192

114-
# rearrange so that link is at top
115-
# need to test
116-
def format_post(self, pet:AdoptablePet) -> Post:
117-
"""
118-
Create a Post from an AdoptablePet.
193+
@staticmethod
194+
def _safe_truncate(text: str, limit: int) -> tuple[str, str]:
195+
if len(text) <= limit:
196+
return text, ""
197+
198+
cut = text.rfind(" ", 0, limit)
199+
200+
if cut == -1:
201+
cut = limit
119202

120-
Override this method to customize post formatting for specific platforms.
121-
"""
122-
text = f"Meet {pet.name}! This adorable {pet.breed} {pet.species} is looking for a forever home in {pet.location}."
203+
return text[:cut].rstrip(), text[cut:].strip()
204+
205+
def format_post(self, pet: AdoptablePet) -> Post:
206+
text = (
207+
f"Meet {pet.name}! This adorable {pet.breed} {pet.species} "
208+
f"is looking for a forever home in {pet.location}."
209+
)
123210

124211
if pet.adoption_url:
125-
text += f" Adopt {pet.name}: {pet.adoption_url}"
126-
212+
text += f" Adopt {pet.name}: {pet.adoption_url}"
213+
127214
if pet.description:
128215
text += f"\n\n{pet.description}"
129216

@@ -135,7 +222,10 @@ def format_post(self, pet:AdoptablePet) -> Post:
135222
text=text,
136223
image_url=pet.image_url,
137224
link=pet.adoption_url,
138-
alt_text=f"Photo of {pet.name}, a {pet.breed} {pet.species} available for adoption",
225+
alt_text=(
226+
f"Photo of {pet.name}, a {pet.breed} {pet.species} "
227+
"available for adoption"
228+
),
139229
tags=[
140230
"adoptdontshop",
141231
"rescue",

0 commit comments

Comments
 (0)