refactor: drop requests dep, route all providers through lib/http urllib wrapper (#393)

Five provider modules (pinterest, threads, instagram, tiktok, youtube_yt)
and watchlist.py each carried a try/except `requests` import with parallel
urllib + requests branches. The urllib path already used the
stdlib-only wrapper at `lib/http.py` (retries, 429 handling, HTTPError).
This collapses every dual-branch into a single `http.get`/`http.post`
call and removes the `requests` dependency from `pyproject.toml`.

Also drops 4 transitive deps (urllib3, certifi, charset-normalizer, idna)
from the lockfile, leaving the skill stdlib-only at runtime.

Tests for tiktok comments and watchlist delivery were rewritten to mock
`lib.http` directly instead of the now-removed `requests` module.

Out of scope but flagged during review: the 13 surviving SC call sites
share a near-identical scaffold and would benefit from a
`http.scrapecreators_get(url, params, token, ...)` helper. Filed for a
follow-up PR rather than expanding scope here.
This commit is contained in:
Trevin Chow
2026-05-15 08:07:43 -07:00
committed by GitHub
parent c845f483d6
commit 80a1a47eef
10 changed files with 218 additions and 596 deletions
+1 -3
View File
@@ -4,9 +4,7 @@ version = "3.2.1"
description = "Multi-source last-30-days research skill" description = "Multi-source last-30-days research skill"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = []
"requests>=2.32,<3",
]
[dependency-groups] [dependency-groups]
dev = [ dev = [
+36 -70
View File
@@ -12,11 +12,6 @@ import sys
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Optional, Set from typing import Any, Dict, List, Optional, Set
try:
import requests as _requests
except ImportError:
_requests = None
from . import dates, http, log from . import dates, http, log
SCRAPECREATORS_BASE = "https://api.scrapecreators.com" SCRAPECREATORS_BASE = "https://api.scrapecreators.com"
@@ -236,30 +231,17 @@ def _user_reels(
""" """
_log(f"User reels: @{handle}") _log(f"User reels: @{handle}")
reels_url = f"{SCRAPECREATORS_BASE}/v1/instagram/user/reels" reels_url = f"{SCRAPECREATORS_BASE}/v1/instagram/user/reels"
if not _requests: try:
try: data = http.get(
from urllib.parse import urlencode reels_url,
params = urlencode({"handle": handle}) params={"handle": handle},
url = f"{reels_url}?{params}" headers=http.scrapecreators_headers(token),
headers = http.scrapecreators_headers(token) timeout=30,
headers["User-Agent"] = http.USER_AGENT retries=2,
data = http.get(url, headers=headers, timeout=30, retries=2) )
except Exception as e: except Exception as e:
_log(f"User reels error (urllib) for @{handle}: {e}") _log(f"User reels error for @{handle}: {e}")
return [] return []
else:
try:
resp = _requests.get(
reels_url,
params={"handle": handle},
headers=http.scrapecreators_headers(token),
timeout=30,
)
resp.raise_for_status()
data = resp.json()
except Exception as e:
_log(f"User reels error for @{handle}: {e}")
return []
raw_items = data.get("items") or data.get("reels") or data.get("data") or [] raw_items = data.get("items") or data.get("reels") or data.get("data") or []
_log(f" -> {len(raw_items)} reels from @{handle}") _log(f" -> {len(raw_items)} reels from @{handle}")
@@ -293,31 +275,17 @@ def search_instagram(
_log(f"Searching Instagram for '{core_topic}' (depth={depth}, count={config['results_per_page']})") _log(f"Searching Instagram for '{core_topic}' (depth={depth}, count={config['results_per_page']})")
if not _requests: try:
_log("requests library not installed, falling back to urllib") data = http.get(
try: f"{SCRAPECREATORS_BASE}/v2/instagram/reels/search",
from urllib.parse import urlencode params={"query": core_topic},
params = urlencode({"query": core_topic}) headers=http.scrapecreators_headers(token),
url = f"{SCRAPECREATORS_BASE}/v2/instagram/reels/search?{params}" timeout=30,
headers = http.scrapecreators_headers(token) retries=2,
headers["User-Agent"] = http.USER_AGENT )
data = http.get(url, headers=headers, timeout=30, retries=2) except Exception as e:
except Exception as e: _log(f"ScrapeCreators error: {e}")
_log(f"ScrapeCreators error (urllib): {e}") return {"items": [], "error": f"{type(e).__name__}: {e}"}
return {"items": [], "error": f"{type(e).__name__}: {e}"}
else:
try:
resp = _requests.get(
f"{SCRAPECREATORS_BASE}/v2/instagram/reels/search",
params={"query": core_topic},
headers=http.scrapecreators_headers(token),
timeout=30,
)
resp.raise_for_status()
data = resp.json()
except Exception as e:
_log(f"ScrapeCreators error: {e}")
return {"items": [], "error": f"{type(e).__name__}: {e}"}
# Items are in the 'reels' array (ScrapeCreators v2 response) # Items are in the 'reels' array (ScrapeCreators v2 response)
raw_items = data.get("reels") or data.get("items") or data.get("data") or [] raw_items = data.get("reels") or data.get("items") or data.get("data") or []
@@ -367,7 +335,7 @@ def fetch_captions(
config = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"]) config = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
max_captions = config["max_captions"] max_captions = config["max_captions"]
if not video_items or not token or not _requests: if not video_items or not token:
return {} return {}
top_items = video_items[:max_captions] top_items = video_items[:max_captions]
@@ -392,26 +360,24 @@ def fetch_captions(
if not url: if not url:
continue continue
try: try:
resp = _requests.get( data = http.get(
f"{SCRAPECREATORS_BASE}/v2/instagram/media/transcript", f"{SCRAPECREATORS_BASE}/v2/instagram/media/transcript",
params={"url": url}, params={"url": url},
headers=http.scrapecreators_headers(token), headers=http.scrapecreators_headers(token),
timeout=15, timeout=15,
retries=1,
) )
if resp.status_code == 200: transcripts = data.get("transcripts") or []
data = resp.json() if transcripts and isinstance(transcripts, list):
transcripts = data.get("transcripts") or [] transcript_text = " ".join(
if transcripts and isinstance(transcripts, list): t.get("text", "") for t in transcripts
# Combine all transcript segments if isinstance(t, dict) and t.get("text")
transcript_text = " ".join( )
t.get("text", "") for t in transcripts if transcript_text:
if isinstance(t, dict) and t.get("text") words = transcript_text.split()
) if len(words) > CAPTION_MAX_WORDS:
if transcript_text: transcript_text = ' '.join(words[:CAPTION_MAX_WORDS]) + '...'
words = transcript_text.split() captions[vid] = transcript_text
if len(words) > CAPTION_MAX_WORDS:
transcript_text = ' '.join(words[:CAPTION_MAX_WORDS]) + '...'
captions[vid] = transcript_text
except Exception as e: except Exception as e:
_log(f"Transcript fetch failed for {vid}: {e}") _log(f"Transcript fetch failed for {vid}: {e}")
+11 -30
View File
@@ -11,11 +11,6 @@ import re
import sys import sys
from typing import Any, Dict, List, Optional, Set from typing import Any, Dict, List, Optional, Set
try:
import requests as _requests
except ImportError:
_requests = None
from . import dates, http, log from . import dates, http, log
SCRAPECREATORS_BASE = "https://api.scrapecreators.com/v1/pinterest" SCRAPECREATORS_BASE = "https://api.scrapecreators.com/v1/pinterest"
@@ -140,31 +135,17 @@ def search_pinterest(
_log(f"Searching Pinterest for '{core_topic}' (depth={depth}, count={config['results_per_page']})") _log(f"Searching Pinterest for '{core_topic}' (depth={depth}, count={config['results_per_page']})")
if not _requests: try:
_log("requests library not installed, falling back to urllib") data = http.get(
try: f"{SCRAPECREATORS_BASE}/search",
from urllib.parse import urlencode params={"keyword": core_topic},
params = urlencode({"keyword": core_topic}) headers=http.scrapecreators_headers(token),
url = f"{SCRAPECREATORS_BASE}/search?{params}" timeout=30,
headers = http.scrapecreators_headers(token) retries=2,
headers["User-Agent"] = http.USER_AGENT )
data = http.get(url, headers=headers, timeout=30, retries=2) except Exception as e:
except Exception as e: _log(f"ScrapeCreators error: {e}")
_log(f"ScrapeCreators error (urllib): {e}") return {"items": [], "error": f"{type(e).__name__}: {e}"}
return {"items": [], "error": f"{type(e).__name__}: {e}"}
else:
try:
resp = _requests.get(
f"{SCRAPECREATORS_BASE}/search",
params={"keyword": core_topic},
headers=http.scrapecreators_headers(token),
timeout=30,
)
resp.raise_for_status()
data = resp.json()
except Exception as e:
_log(f"ScrapeCreators error: {e}")
return {"items": [], "error": f"{type(e).__name__}: {e}"}
# Extract items from response - try common SC response shapes # Extract items from response - try common SC response shapes
raw_items = data.get("pins") or data.get("results") or data.get("data") or data.get("items") or [] raw_items = data.get("pins") or data.get("results") or data.get("data") or data.get("items") or []
+10 -29
View File
@@ -152,35 +152,16 @@ def search_threads(
_log(f"Searching for '{core_topic}' (depth={depth}, limit={config['results']})") _log(f"Searching for '{core_topic}' (depth={depth}, limit={config['results']})")
try: try:
import requests as _requests data = http.get(
except ImportError: f"{SCRAPECREATORS_BASE}/search",
_requests = None params={"keyword": core_topic},
headers=http.scrapecreators_headers(token),
if not _requests: timeout=30,
_log("requests library not installed, falling back to urllib") retries=2,
try: )
from urllib.parse import urlencode except Exception as e:
params = urlencode({"keyword": core_topic}) _log(f"ScrapeCreators error: {e}")
url = f"{SCRAPECREATORS_BASE}/search?{params}" return {"items": [], "error": f"{type(e).__name__}: {e}"}
headers = http.scrapecreators_headers(token)
headers["User-Agent"] = http.USER_AGENT
data = http.get(url, headers=headers, timeout=30, retries=2)
except Exception as e:
_log(f"ScrapeCreators error (urllib): {e}")
return {"items": [], "error": f"{type(e).__name__}: {e}"}
else:
try:
resp = _requests.get(
f"{SCRAPECREATORS_BASE}/search",
params={"keyword": core_topic},
headers=http.scrapecreators_headers(token),
timeout=30,
)
resp.raise_for_status()
data = resp.json()
except Exception as e:
_log(f"ScrapeCreators error: {e}")
return {"items": [], "error": f"{type(e).__name__}: {e}"}
# Extract items from response (try common SC response shapes) # Extract items from response (try common SC response shapes)
raw_items = ( raw_items = (
+56 -115
View File
@@ -11,11 +11,6 @@ import re
import sys import sys
from typing import Any, Dict, List, Optional, Set from typing import Any, Dict, List, Optional, Set
try:
import requests as _requests
except ImportError:
_requests = None
from . import dates, http, log from . import dates, http, log
SCRAPECREATORS_BASE = "https://api.scrapecreators.com/v1/tiktok" SCRAPECREATORS_BASE = "https://api.scrapecreators.com/v1/tiktok"
@@ -214,30 +209,17 @@ def _hashtag_search(
List of raw TikTok item dicts (aweme_info format). List of raw TikTok item dicts (aweme_info format).
""" """
_log(f"Hashtag search: #{hashtag}") _log(f"Hashtag search: #{hashtag}")
if not _requests: try:
try: data = http.get(
from urllib.parse import urlencode f"{SCRAPECREATORS_BASE}/search/hashtag",
params = urlencode({"hashtag": hashtag}) params={"hashtag": hashtag},
url = f"{SCRAPECREATORS_BASE}/search/hashtag?{params}" headers=http.scrapecreators_headers(token),
headers = http.scrapecreators_headers(token) timeout=30,
headers["User-Agent"] = http.USER_AGENT retries=2,
data = http.get(url, headers=headers, timeout=30, retries=2) )
except Exception as e: except Exception as e:
_log(f"Hashtag search error (urllib) for #{hashtag}: {e}") _log(f"Hashtag search error for #{hashtag}: {e}")
return [] return []
else:
try:
resp = _requests.get(
f"{SCRAPECREATORS_BASE}/search/hashtag",
params={"hashtag": hashtag},
headers=http.scrapecreators_headers(token),
timeout=30,
)
resp.raise_for_status()
data = resp.json()
except Exception as e:
_log(f"Hashtag search error for #{hashtag}: {e}")
return []
raw_items = data.get("aweme_list") or data.get("data") or [] raw_items = data.get("aweme_list") or data.get("data") or []
_log(f" -> {len(raw_items)} results for #{hashtag}") _log(f" -> {len(raw_items)} results for #{hashtag}")
@@ -261,30 +243,17 @@ def _profile_videos(
""" """
_log(f"Profile videos: @{handle}") _log(f"Profile videos: @{handle}")
profile_url = "https://api.scrapecreators.com/v3/tiktok/profile/videos" profile_url = "https://api.scrapecreators.com/v3/tiktok/profile/videos"
if not _requests: try:
try: data = http.get(
from urllib.parse import urlencode profile_url,
params = urlencode({"handle": handle, "sort_by": "latest"}) params={"handle": handle, "sort_by": "latest"},
url = f"{profile_url}?{params}" headers=http.scrapecreators_headers(token),
headers = http.scrapecreators_headers(token) timeout=30,
headers["User-Agent"] = http.USER_AGENT retries=2,
data = http.get(url, headers=headers, timeout=30, retries=2) )
except Exception as e: except Exception as e:
_log(f"Profile videos error (urllib) for @{handle}: {e}") _log(f"Profile videos error for @{handle}: {e}")
return [] return []
else:
try:
resp = _requests.get(
profile_url,
params={"handle": handle, "sort_by": "latest"},
headers=http.scrapecreators_headers(token),
timeout=30,
)
resp.raise_for_status()
data = resp.json()
except Exception as e:
_log(f"Profile videos error for @{handle}: {e}")
return []
raw_items = data.get("aweme_list") or data.get("data") or [] raw_items = data.get("aweme_list") or data.get("data") or []
_log(f" -> {len(raw_items)} videos from @{handle}") _log(f" -> {len(raw_items)} videos from @{handle}")
@@ -318,31 +287,17 @@ def search_tiktok(
_log(f"Searching TikTok for '{core_topic}' (depth={depth}, count={config['results_per_page']})") _log(f"Searching TikTok for '{core_topic}' (depth={depth}, count={config['results_per_page']})")
if not _requests: try:
_log("requests library not installed, falling back to urllib") data = http.get(
try: f"{SCRAPECREATORS_BASE}/search/keyword",
from urllib.parse import urlencode params={"query": core_topic, "sort_by": "relevance"},
params = urlencode({"query": core_topic, "sort_by": "relevance"}) headers=http.scrapecreators_headers(token),
url = f"{SCRAPECREATORS_BASE}/search/keyword?{params}" timeout=30,
headers = http.scrapecreators_headers(token) retries=2,
headers["User-Agent"] = http.USER_AGENT )
data = http.get(url, headers=headers, timeout=30, retries=2) except Exception as e:
except Exception as e: _log(f"ScrapeCreators error: {e}")
_log(f"ScrapeCreators error (urllib): {e}") return {"items": [], "error": f"{type(e).__name__}: {e}"}
return {"items": [], "error": f"{type(e).__name__}: {e}"}
else:
try:
resp = _requests.get(
f"{SCRAPECREATORS_BASE}/search/keyword",
params={"query": core_topic, "sort_by": "relevance"},
headers=http.scrapecreators_headers(token),
timeout=30,
)
resp.raise_for_status()
data = resp.json()
except Exception as e:
_log(f"ScrapeCreators error: {e}")
return {"items": [], "error": f"{type(e).__name__}: {e}"}
# Items are nested under aweme_info # Items are nested under aweme_info
raw_entries = data.get("search_item_list") or data.get("data") or [] raw_entries = data.get("search_item_list") or data.get("data") or []
@@ -397,7 +352,7 @@ def fetch_captions(
config = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"]) config = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
max_captions = config["max_captions"] max_captions = config["max_captions"]
if not video_items or not token or not _requests: if not video_items or not token:
return {} return {}
top_items = video_items[:max_captions] top_items = video_items[:max_captions]
@@ -422,24 +377,23 @@ def fetch_captions(
if not url: if not url:
continue continue
try: try:
resp = _requests.get( data = http.get(
f"{SCRAPECREATORS_BASE}/video/transcript", f"{SCRAPECREATORS_BASE}/video/transcript",
params={"url": url}, params={"url": url},
headers=http.scrapecreators_headers(token), headers=http.scrapecreators_headers(token),
timeout=15, timeout=15,
retries=1,
) )
if resp.status_code == 200: transcript = data.get("transcript")
data = resp.json() if transcript:
transcript = data.get("transcript") if isinstance(transcript, list):
transcript = " ".join(str(s) for s in transcript)
transcript = _clean_webvtt(transcript)
if transcript: if transcript:
if isinstance(transcript, list): words = transcript.split()
transcript = " ".join(str(s) for s in transcript) if len(words) > CAPTION_MAX_WORDS:
transcript = _clean_webvtt(transcript) transcript = ' '.join(words[:CAPTION_MAX_WORDS]) + '...'
if transcript: captions[vid] = transcript
words = transcript.split()
if len(words) > CAPTION_MAX_WORDS:
transcript = ' '.join(words[:CAPTION_MAX_WORDS]) + '...'
captions[vid] = transcript
except Exception as e: except Exception as e:
_log(f"Transcript fetch failed for {vid}: {e}") _log(f"Transcript fetch failed for {vid}: {e}")
@@ -620,30 +574,17 @@ def _fetch_post_comments(
List of comment dicts with author, text, digg_count (likes), date. List of comment dicts with author, text, digg_count (likes), date.
Empty list on any error — comment failures never crash the pipeline. Empty list on any error — comment failures never crash the pipeline.
""" """
if not _requests: try:
try: data = http.get(
from urllib.parse import urlencode f"{SCRAPECREATORS_BASE}/video/comments",
params = urlencode({"url": post_url, "trim": "true"}) params={"url": post_url, "trim": "true"},
url = f"{SCRAPECREATORS_BASE}/video/comments?{params}" headers=http.scrapecreators_headers(token),
headers = http.scrapecreators_headers(token) timeout=30,
headers["User-Agent"] = http.USER_AGENT retries=2,
data = http.get(url, headers=headers, timeout=30, retries=2) )
except Exception as exc: except Exception as exc:
_log(f"Comment fetch error (urllib) for {post_url}: {exc}") _log(f"Comment fetch error for {post_url}: {exc}")
return [] return []
else:
try:
resp = _requests.get(
f"{SCRAPECREATORS_BASE}/video/comments",
params={"url": post_url, "trim": "true"},
headers=http.scrapecreators_headers(token),
timeout=30,
)
resp.raise_for_status()
data = resp.json()
except Exception as exc:
_log(f"Comment fetch error for {post_url}: {exc}")
return []
raw_comments = data.get("comments") or data.get("data") or [] raw_comments = data.get("comments") or data.get("data") or []
# Sort by digg_count desc so normalize sees the highest-signal first. # Sort by digg_count desc so normalize sees the highest-signal first.
+24 -71
View File
@@ -617,11 +617,6 @@ def parse_youtube_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
SCRAPECREATORS_YT_BASE = "https://api.scrapecreators.com/v1/youtube" SCRAPECREATORS_YT_BASE = "https://api.scrapecreators.com/v1/youtube"
try:
import requests as _requests
except ImportError:
_requests = None
def _total_engagement(item: Dict[str, Any]) -> int: def _total_engagement(item: Dict[str, Any]) -> int:
"""Combined engagement score for ranking which videos to enrich.""" """Combined engagement score for ranking which videos to enrich."""
@@ -701,30 +696,17 @@ def _fetch_video_comments(
List of comment dicts with author, text, likes, date. List of comment dicts with author, text, likes, date.
""" """
video_url = f"https://www.youtube.com/watch?v={video_id}" video_url = f"https://www.youtube.com/watch?v={video_id}"
if not _requests: try:
try: data = http.get(
from urllib.parse import urlencode f"{SCRAPECREATORS_YT_BASE}/video/comments",
params = urlencode({"url": video_url}) params={"url": video_url},
url = f"{SCRAPECREATORS_YT_BASE}/video/comments?{params}" headers=http.scrapecreators_headers(token),
headers = http.scrapecreators_headers(token) timeout=30,
headers["User-Agent"] = http.USER_AGENT retries=2,
data = http.get(url, headers=headers, timeout=30, retries=2) )
except Exception as exc: except Exception as exc:
_log(f"Comment fetch error (urllib) for {video_id}: {exc}") _log(f"Comment fetch error for {video_id}: {exc}")
return [] return []
else:
try:
resp = _requests.get(
f"{SCRAPECREATORS_YT_BASE}/video/comments",
params={"url": video_url},
headers=http.scrapecreators_headers(token),
timeout=30,
)
resp.raise_for_status()
data = resp.json()
except Exception as exc:
_log(f"Comment fetch error for {video_id}: {exc}")
return []
raw_comments = data.get("comments", data.get("data", [])) raw_comments = data.get("comments", data.get("data", []))
comments = [] comments = []
@@ -883,28 +865,14 @@ def _sc_youtube_search(keyword: str, token: str) -> List[Dict[str, Any]]:
Returns: Returns:
List of raw video dicts from the API. List of raw video dicts from the API.
""" """
if not _requests:
try:
from urllib.parse import urlencode
params = urlencode({"keyword": keyword})
url = f"{SCRAPECREATORS_YT_BASE}/search?{params}"
headers = http.scrapecreators_headers(token)
headers["User-Agent"] = http.USER_AGENT
data = http.get(url, headers=headers, timeout=30, retries=2)
return data.get("videos", data.get("data", data.get("items", [])))
except Exception as exc:
_log(f"SC YouTube search error (urllib): {exc}")
return []
try: try:
resp = _requests.get( data = http.get(
f"{SCRAPECREATORS_YT_BASE}/search", f"{SCRAPECREATORS_YT_BASE}/search",
params={"keyword": keyword}, params={"keyword": keyword},
headers=http.scrapecreators_headers(token), headers=http.scrapecreators_headers(token),
timeout=30, timeout=30,
retries=2,
) )
resp.raise_for_status()
data = resp.json()
return data.get("videos", data.get("data", data.get("items", []))) return data.get("videos", data.get("data", data.get("items", [])))
except Exception as exc: except Exception as exc:
_log(f"SC YouTube search error: {exc}") _log(f"SC YouTube search error: {exc}")
@@ -922,32 +890,17 @@ def _sc_fetch_transcript(video_id: str, token: str) -> Optional[str]:
Plaintext transcript string, or None if unavailable. Plaintext transcript string, or None if unavailable.
""" """
video_url = f"https://www.youtube.com/watch?v={video_id}" video_url = f"https://www.youtube.com/watch?v={video_id}"
if not _requests: try:
try: data = http.get(
from urllib.parse import urlencode f"{SCRAPECREATORS_YT_BASE}/video/transcript",
params = urlencode({"url": video_url}) params={"url": video_url},
url = f"{SCRAPECREATORS_YT_BASE}/video/transcript?{params}" headers=http.scrapecreators_headers(token),
headers = http.scrapecreators_headers(token) timeout=30,
headers["User-Agent"] = http.USER_AGENT retries=1,
data = http.get(url, headers=headers, timeout=30, retries=2) )
except Exception as exc: except Exception as exc:
_log(f"SC transcript error (urllib) for {video_id}: {exc}") _log(f"SC transcript error for {video_id}: {exc}")
return None return None
else:
try:
resp = _requests.get(
f"{SCRAPECREATORS_YT_BASE}/video/transcript",
params={"url": video_url},
headers=http.scrapecreators_headers(token),
timeout=30,
)
if resp.status_code != 200:
_log(f"SC transcript returned {resp.status_code} for {video_id}")
return None
data = resp.json()
except Exception as exc:
_log(f"SC transcript error for {video_id}: {exc}")
return None
transcript = data.get("transcript") transcript = data.get("transcript")
if not transcript: if not transcript:
+5 -23
View File
@@ -10,16 +10,11 @@ import sys
import time import time
from pathlib import Path from pathlib import Path
try:
import requests
except ImportError:
requests = None
SCRIPT_DIR = Path(__file__).parent.resolve() SCRIPT_DIR = Path(__file__).parent.resolve()
sys.path.insert(0, str(SCRIPT_DIR)) sys.path.insert(0, str(SCRIPT_DIR))
import store import store
from lib import schema from lib import http, schema
# --- Webhook Delivery Functions --- # --- Webhook Delivery Functions ---
@@ -58,34 +53,21 @@ def _format_delivery_message(topic: str, counts: dict, mode: str) -> str:
def _send_slack_webhook(url: str, text: str) -> None: def _send_slack_webhook(url: str, text: str) -> None:
"""POST to Slack incoming webhook.""" """POST to Slack incoming webhook."""
if not requests: http.post(url, json_data={"text": text}, timeout=10, retries=1)
raise RuntimeError("requests library not available for webhook delivery")
response = requests.post(
url,
json={"text": text},
headers={"Content-Type": "application/json"},
timeout=10,
)
response.raise_for_status()
def _send_generic_webhook(url: str, text: str) -> None: def _send_generic_webhook(url: str, text: str) -> None:
"""POST JSON payload to generic webhook.""" """POST JSON payload to generic webhook."""
if not requests: http.post(
raise RuntimeError("requests library not available for webhook delivery")
response = requests.post(
url, url,
json={ json_data={
"message": text, "message": text,
"source": "last30days", "source": "last30days",
"timestamp": time.time(), "timestamp": time.time(),
}, },
headers={"Content-Type": "application/json"},
timeout=10, timeout=10,
retries=1,
) )
response.raise_for_status()
# --- Command Handlers --- # --- Command Handlers ---
+3 -18
View File
@@ -154,14 +154,7 @@ class TestTikTokEnrichWithComments(unittest.TestCase):
"total": 3, "total": 3,
} }
class FakeResp: with patch.object(tiktok.http, "get", return_value=fake_sc_response):
def raise_for_status(self):
pass
def json(self):
return fake_sc_response
with patch.object(tiktok, "_requests") as mock_req:
mock_req.get.return_value = FakeResp()
out = tiktok._fetch_post_comments( out = tiktok._fetch_post_comments(
"https://www.tiktok.com/@u/video/1", "https://www.tiktok.com/@u/video/1",
token="k", token="k",
@@ -192,14 +185,7 @@ class TestTikTokEnrichWithComments(unittest.TestCase):
"total": 3, "total": 3,
} }
class FakeResp: with patch.object(tiktok.http, "get", return_value=fake_sc_response):
def raise_for_status(self):
pass
def json(self):
return fake_sc_response
with patch.object(tiktok, "_requests") as mock_req:
mock_req.get.return_value = FakeResp()
out = tiktok._fetch_post_comments( out = tiktok._fetch_post_comments(
"https://www.tiktok.com/@u/video/1", "https://www.tiktok.com/@u/video/1",
token="k", token="k",
@@ -217,8 +203,7 @@ class TestTikTokEnrichWithComments(unittest.TestCase):
from unittest.mock import patch from unittest.mock import patch
from lib import tiktok from lib import tiktok
with patch.object(tiktok, "_requests") as mock_req: with patch.object(tiktok.http, "get", side_effect=Exception("429 rate limit")):
mock_req.get.side_effect = Exception("429 rate limit")
out = tiktok._fetch_post_comments( out = tiktok._fetch_post_comments(
"https://www.tiktok.com/@u/video/1", "https://www.tiktok.com/@u/video/1",
token="k", token="k",
+71 -117
View File
@@ -2,13 +2,14 @@
import sys import sys
from pathlib import Path from pathlib import Path
from unittest.mock import Mock, patch from unittest.mock import patch
import pytest import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "skills" / "last30days" / "scripts")) sys.path.insert(0, str(Path(__file__).parent.parent / "skills" / "last30days" / "scripts"))
import watchlist import watchlist
from lib.http import HTTPError
# === Tests for _format_delivery_message() === # === Tests for _format_delivery_message() ===
@@ -18,7 +19,7 @@ def test_format_message_announce_mode():
message = watchlist._format_delivery_message( message = watchlist._format_delivery_message(
"Test Topic", {"new": 5, "updated": 2}, "announce" "Test Topic", {"new": 5, "updated": 2}, "announce"
) )
assert "📰" in message assert "📰" in message
assert "Test Topic" in message assert "Test Topic" in message
assert "5 new" in message assert "5 new" in message
@@ -30,7 +31,7 @@ def test_format_message_silent_mode():
message = watchlist._format_delivery_message( message = watchlist._format_delivery_message(
"Test Topic", {"new": 5, "updated": 2}, "silent" "Test Topic", {"new": 5, "updated": 2}, "silent"
) )
assert "📰" not in message assert "📰" not in message
assert "Test Topic" in message assert "Test Topic" in message
assert "5 new" in message assert "5 new" in message
@@ -41,7 +42,7 @@ def test_format_message_default_mode():
message = watchlist._format_delivery_message( message = watchlist._format_delivery_message(
"Test Topic", {"new": 5, "updated": 2}, "default" "Test Topic", {"new": 5, "updated": 2}, "default"
) )
assert "complete" in message.lower() assert "complete" in message.lower()
assert "Test Topic" in message assert "Test Topic" in message
@@ -51,44 +52,35 @@ def test_format_message_handles_zero_counts():
message = watchlist._format_delivery_message( message = watchlist._format_delivery_message(
"Test Topic", {"new": 0, "updated": 0}, "announce" "Test Topic", {"new": 0, "updated": 0}, "announce"
) )
assert "0 new" in message assert "0 new" in message
assert "0 updated" in message assert "0 updated" in message
# === Tests for _send_slack_webhook() === # === Tests for _send_slack_webhook() ===
@patch('watchlist.requests') @patch('watchlist.http.post')
def test_send_slack_webhook_format(mock_requests): def test_send_slack_webhook_format(mock_post):
"""Test that Slack webhook uses correct format.""" """Test that Slack webhook uses correct format."""
mock_response = Mock()
mock_response.status_code = 200
mock_requests.post.return_value = mock_response
watchlist._send_slack_webhook( watchlist._send_slack_webhook(
"https://hooks.slack.com/services/TEST", "https://hooks.slack.com/services/TEST",
"Test message" "Test message"
) )
# Verify POST was called with correct format assert mock_post.called
assert mock_requests.post.called call_args = mock_post.call_args
call_args = mock_requests.post.call_args
assert call_args[0][0] == "https://hooks.slack.com/services/TEST" assert call_args[0][0] == "https://hooks.slack.com/services/TEST"
assert call_args[1]["json"] == {"text": "Test message"} assert call_args[1]["json_data"] == {"text": "Test message"}
assert call_args[1]["headers"]["Content-Type"] == "application/json"
assert call_args[1]["timeout"] == 10 assert call_args[1]["timeout"] == 10
@patch('watchlist.requests') @patch('watchlist.http.post')
def test_send_slack_webhook_raises_on_error(mock_requests): def test_send_slack_webhook_raises_on_error(mock_post):
"""Test that Slack webhook raises on HTTP error.""" """Test that Slack webhook raises on HTTP error."""
mock_response = Mock() mock_post.side_effect = HTTPError("HTTP 400", 400)
mock_response.status_code = 400
mock_response.raise_for_status.side_effect = Exception("HTTP 400") with pytest.raises(HTTPError, match="HTTP 400"):
mock_requests.post.return_value = mock_response
with pytest.raises(Exception, match="HTTP 400"):
watchlist._send_slack_webhook( watchlist._send_slack_webhook(
"https://hooks.slack.com/services/TEST", "https://hooks.slack.com/services/TEST",
"Test message" "Test message"
@@ -97,40 +89,32 @@ def test_send_slack_webhook_raises_on_error(mock_requests):
# === Tests for _send_generic_webhook() === # === Tests for _send_generic_webhook() ===
@patch('watchlist.requests') @patch('watchlist.http.post')
def test_send_generic_webhook_format(mock_requests): def test_send_generic_webhook_format(mock_post):
"""Test that generic webhook uses correct format.""" """Test that generic webhook uses correct format."""
mock_response = Mock()
mock_response.status_code = 200
mock_requests.post.return_value = mock_response
watchlist._send_generic_webhook( watchlist._send_generic_webhook(
"https://webhook.example.com/hook", "https://webhook.example.com/hook",
"Test message" "Test message"
) )
# Verify POST was called with correct format assert mock_post.called
assert mock_requests.post.called call_args = mock_post.call_args
call_args = mock_requests.post.call_args
assert call_args[0][0] == "https://webhook.example.com/hook" assert call_args[0][0] == "https://webhook.example.com/hook"
json_data = call_args[1]["json"] json_data = call_args[1]["json_data"]
assert json_data["message"] == "Test message" assert json_data["message"] == "Test message"
assert json_data["source"] == "last30days" assert json_data["source"] == "last30days"
assert "timestamp" in json_data assert "timestamp" in json_data
assert isinstance(json_data["timestamp"], float) assert isinstance(json_data["timestamp"], float)
@patch('watchlist.requests') @patch('watchlist.http.post')
def test_send_generic_webhook_raises_on_error(mock_requests): def test_send_generic_webhook_raises_on_error(mock_post):
"""Test that generic webhook raises on HTTP error.""" """Test that generic webhook raises on HTTP error."""
mock_response = Mock() mock_post.side_effect = HTTPError("HTTP 500", 500)
mock_response.status_code = 500
mock_response.raise_for_status.side_effect = Exception("HTTP 500") with pytest.raises(HTTPError, match="HTTP 500"):
mock_requests.post.return_value = mock_response
with pytest.raises(Exception, match="HTTP 500"):
watchlist._send_generic_webhook( watchlist._send_generic_webhook(
"https://webhook.example.com/hook", "https://webhook.example.com/hook",
"Test message" "Test message"
@@ -140,150 +124,120 @@ def test_send_generic_webhook_raises_on_error(mock_requests):
# === Tests for _deliver_findings() === # === Tests for _deliver_findings() ===
@patch('watchlist.store.get_setting') @patch('watchlist.store.get_setting')
@patch('watchlist.requests') @patch('watchlist.http.post')
def test_deliver_findings_sends_when_new_greater_than_zero(mock_requests, mock_get_setting): def test_deliver_findings_sends_when_new_greater_than_zero(mock_post, mock_get_setting):
"""Test that delivery fires when new > 0.""" """Test that delivery fires when new > 0."""
mock_get_setting.side_effect = lambda key, default="": { mock_get_setting.side_effect = lambda key, default="": {
"delivery_channel": "https://webhook.example.com/test", "delivery_channel": "https://webhook.example.com/test",
"delivery_mode": "announce", "delivery_mode": "announce",
}.get(key, default) }.get(key, default)
mock_response = Mock()
mock_response.status_code = 200
mock_requests.post.return_value = mock_response
watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2}) watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2})
# Verify webhook was called assert mock_post.called
assert mock_requests.post.called
@patch('watchlist.store.get_setting') @patch('watchlist.store.get_setting')
@patch('watchlist.requests') @patch('watchlist.http.post')
def test_deliver_findings_skips_when_new_is_zero(mock_requests, mock_get_setting): def test_deliver_findings_skips_when_new_is_zero(mock_post, mock_get_setting):
"""Test that delivery is skipped when new=0.""" """Test that delivery is skipped when new=0."""
mock_get_setting.side_effect = lambda key, default="": { mock_get_setting.side_effect = lambda key, default="": {
"delivery_channel": "https://webhook.example.com/test", "delivery_channel": "https://webhook.example.com/test",
"delivery_mode": "announce", "delivery_mode": "announce",
}.get(key, default) }.get(key, default)
watchlist._deliver_findings("Test Topic", {"new": 0, "updated": 5}) watchlist._deliver_findings("Test Topic", {"new": 0, "updated": 5})
# Verify webhook was NOT called assert not mock_post.called
assert not mock_requests.post.called
@patch('watchlist.store.get_setting') @patch('watchlist.store.get_setting')
@patch('watchlist.requests') @patch('watchlist.http.post')
def test_deliver_findings_skips_when_channel_empty(mock_requests, mock_get_setting): def test_deliver_findings_skips_when_channel_empty(mock_post, mock_get_setting):
"""Test that delivery is skipped when delivery_channel is empty.""" """Test that delivery is skipped when delivery_channel is empty."""
mock_get_setting.side_effect = lambda key, default="": { mock_get_setting.side_effect = lambda key, default="": {
"delivery_channel": "", "delivery_channel": "",
"delivery_mode": "announce", "delivery_mode": "announce",
}.get(key, default) }.get(key, default)
watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2}) watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2})
# Verify webhook was NOT called assert not mock_post.called
assert not mock_requests.post.called
@patch('watchlist.store.get_setting') @patch('watchlist.store.get_setting')
@patch('watchlist.requests') @patch('watchlist.http.post')
def test_deliver_findings_uses_slack_format_for_slack_urls(mock_requests, mock_get_setting): def test_deliver_findings_uses_slack_format_for_slack_urls(mock_post, mock_get_setting):
"""Test that Slack URLs trigger Slack-specific format.""" """Test that Slack URLs trigger Slack-specific format."""
mock_get_setting.side_effect = lambda key, default="": { mock_get_setting.side_effect = lambda key, default="": {
"delivery_channel": "https://hooks.slack.com/services/TEST", "delivery_channel": "https://hooks.slack.com/services/TEST",
"delivery_mode": "announce", "delivery_mode": "announce",
}.get(key, default) }.get(key, default)
mock_response = Mock()
mock_response.status_code = 200
mock_requests.post.return_value = mock_response
watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2}) watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2})
# Verify Slack format was used json_data = mock_post.call_args[1]["json_data"]
call_args = mock_requests.post.call_args
json_data = call_args[1]["json"]
assert "text" in json_data assert "text" in json_data
assert "Test Topic" in json_data["text"] assert "Test Topic" in json_data["text"]
@patch('watchlist.store.get_setting') @patch('watchlist.store.get_setting')
@patch('watchlist.requests') @patch('watchlist.http.post')
def test_deliver_findings_uses_generic_format_for_other_urls(mock_requests, mock_get_setting): def test_deliver_findings_uses_generic_format_for_other_urls(mock_post, mock_get_setting):
"""Test that non-Slack URLs trigger generic format.""" """Test that non-Slack URLs trigger generic format."""
mock_get_setting.side_effect = lambda key, default="": { mock_get_setting.side_effect = lambda key, default="": {
"delivery_channel": "https://webhook.example.com/test", "delivery_channel": "https://webhook.example.com/test",
"delivery_mode": "announce", "delivery_mode": "announce",
}.get(key, default) }.get(key, default)
mock_response = Mock()
mock_response.status_code = 200
mock_requests.post.return_value = mock_response
watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2}) watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2})
# Verify generic format was used json_data = mock_post.call_args[1]["json_data"]
call_args = mock_requests.post.call_args
json_data = call_args[1]["json"]
assert "message" in json_data assert "message" in json_data
assert "source" in json_data assert "source" in json_data
assert "timestamp" in json_data assert "timestamp" in json_data
@patch('watchlist.store.get_setting') @patch('watchlist.store.get_setting')
@patch('watchlist.requests') @patch('watchlist.http.post')
def test_deliver_findings_handles_failure_gracefully(mock_requests, mock_get_setting, capsys): def test_deliver_findings_handles_failure_gracefully(mock_post, mock_get_setting, capsys):
"""Test that delivery failures don't crash the process.""" """Test that delivery failures don't crash the process."""
mock_get_setting.side_effect = lambda key, default="": { mock_get_setting.side_effect = lambda key, default="": {
"delivery_channel": "https://webhook.example.com/test", "delivery_channel": "https://webhook.example.com/test",
"delivery_mode": "announce", "delivery_mode": "announce",
}.get(key, default) }.get(key, default)
# Simulate HTTP error mock_post.side_effect = HTTPError("HTTP 500", 500)
mock_response = Mock()
mock_response.status_code = 500
mock_response.raise_for_status.side_effect = Exception("HTTP 500")
mock_requests.post.return_value = mock_response
# Should not raise, just log to stderr # Should not raise, just log to stderr
watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2}) watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2})
# Verify error was logged
captured = capsys.readouterr() captured = capsys.readouterr()
assert "Delivery failed" in captured.err assert "Delivery failed" in captured.err
@patch('watchlist.store.get_setting') @patch('watchlist.store.get_setting')
@patch('watchlist.requests') @patch('watchlist.http.post')
def test_deliver_findings_respects_delivery_mode(mock_requests, mock_get_setting): def test_deliver_findings_respects_delivery_mode(mock_post, mock_get_setting):
"""Test that different delivery modes produce different messages.""" """Test that different delivery modes produce different messages."""
mock_response = Mock()
mock_response.status_code = 200
mock_requests.post.return_value = mock_response
# Test announce mode
mock_get_setting.side_effect = lambda key, default="": { mock_get_setting.side_effect = lambda key, default="": {
"delivery_channel": "https://webhook.example.com/test", "delivery_channel": "https://webhook.example.com/test",
"delivery_mode": "announce", "delivery_mode": "announce",
}.get(key, default) }.get(key, default)
watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2}) watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2})
announce_message = mock_requests.post.call_args[1]["json"]["message"] announce_message = mock_post.call_args[1]["json_data"]["message"]
assert "📰" in announce_message assert "📰" in announce_message
# Test silent mode
mock_get_setting.side_effect = lambda key, default="": { mock_get_setting.side_effect = lambda key, default="": {
"delivery_channel": "https://webhook.example.com/test", "delivery_channel": "https://webhook.example.com/test",
"delivery_mode": "silent", "delivery_mode": "silent",
}.get(key, default) }.get(key, default)
watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2}) watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2})
silent_message = mock_requests.post.call_args[1]["json"]["message"] silent_message = mock_post.call_args[1]["json_data"]["message"]
assert "📰" not in silent_message assert "📰" not in silent_message
Generated
+1 -120
View File
@@ -2,88 +2,6 @@ version = 1
revision = 3 revision = 3
requires-python = ">=3.12" requires-python = ">=3.12"
[[package]]
name = "certifi"
version = "2026.2.25"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" },
{ url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" },
{ url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" },
{ url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" },
{ url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" },
{ url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" },
{ url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" },
{ url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" },
{ url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" },
{ url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" },
{ url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" },
{ url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" },
{ url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" },
{ url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" },
{ url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" },
{ url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" },
{ url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" },
{ url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" },
{ url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" },
{ url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" },
{ url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" },
{ url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" },
{ url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" },
{ url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" },
{ url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" },
{ url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" },
{ url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" },
{ url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" },
{ url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" },
{ url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" },
{ url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" },
{ url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" },
{ url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" },
{ url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" },
{ url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" },
{ url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" },
{ url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" },
{ url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" },
{ url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" },
{ url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" },
{ url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" },
{ url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" },
{ url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" },
{ url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" },
{ url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" },
{ url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" },
{ url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" },
{ url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" },
{ url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" },
{ url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" },
{ url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" },
{ url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" },
{ url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" },
{ url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" },
{ url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" },
{ url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" },
{ url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" },
{ url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" },
{ url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" },
{ url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" },
{ url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" },
{ url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" },
{ url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" },
{ url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" },
]
[[package]] [[package]]
name = "colorama" name = "colorama"
version = "0.4.6" version = "0.4.6"
@@ -177,15 +95,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
] ]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]] [[package]]
name = "iniconfig" name = "iniconfig"
version = "2.3.0" version = "2.3.0"
@@ -197,11 +106,8 @@ wheels = [
[[package]] [[package]]
name = "last30days-skill" name = "last30days-skill"
version = "3.1.1" version = "3.2.1"
source = { virtual = "." } source = { virtual = "." }
dependencies = [
{ name = "requests" },
]
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
@@ -210,7 +116,6 @@ dev = [
] ]
[package.metadata] [package.metadata]
requires-dist = [{ name = "requests", specifier = ">=2.32,<3" }]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
@@ -274,27 +179,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
] ]
[[package]]
name = "requests"
version = "2.33.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" },
]
[[package]]
name = "urllib3"
version = "2.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]