From 4c08d093049b9eb78966dac2d51f4fc950d68187 Mon Sep 17 00:00:00 2001 From: jackwener Date: Thu, 5 Mar 2026 16:13:54 +0800 Subject: [PATCH] refactor: harden CLI/client/config and centralize serialization --- README.md | 52 +-- pyproject.toml | 14 + twitter_cli/auth.py | 9 +- twitter_cli/cli.py | 425 +++++------------ twitter_cli/client.py | 852 ++++++++++++++--------------------- twitter_cli/config.py | 226 +++++----- twitter_cli/filter.py | 77 ++-- twitter_cli/formatter.py | 46 +- twitter_cli/serialization.py | 147 ++++++ uv.lock | 331 ++++++++++++++ 10 files changed, 1145 insertions(+), 1034 deletions(-) create mode 100644 twitter_cli/serialization.py diff --git a/README.md b/README.md index 427c9e2..ae87c60 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Twitter CLI -Twitter/X 命令行工具 — 读取 Timeline、管理推文。 +Twitter/X 命令行工具 — 读取 Timeline、书签和用户信息。 **零 API Key** — 使用浏览器 Cookie 认证,免费访问 Twitter。 @@ -22,14 +22,17 @@ twitter feed ### 读取 ```bash -# 抓取首页 timeline +# 抓取首页 timeline(For You 算法推荐) twitter feed +# 抓取关注的人的 timeline(Following 时间线) +twitter feed -t following + # 自定义抓取条数 twitter feed --max 50 -# 跳过筛选 -twitter feed --no-filter +# 开启筛选(按 score 排序过滤) +twitter feed --filter # JSON 输出 twitter feed --json > tweets.json @@ -51,31 +54,6 @@ twitter user elonmusk # 列出用户推文 twitter user-posts elonmusk --max 20 - -# 查看粉丝 -twitter followers elonmusk --max 30 - -# 查看关注 -twitter following elonmusk --max 30 -``` - -### 发推 - -```bash -# 发新推文 -twitter post "Hello World" - -# 回复推文 -twitter reply "这是回复内容" - -# 引用转推(传 URL 或 ID 均可) -twitter quote "这是引用内容" - -# 删除推文(会有确认提示) -twitter delete - -# 跳过删除确认 -twitter delete --yes ``` ## Pipeline @@ -132,14 +110,28 @@ export TWITTER_CT0=your_ct0 twitter_cli/ ├── __init__.py # 版本信息 ├── cli.py # CLI 入口 (click) -├── client.py # Twitter GraphQL API Client (GET + POST) +├── client.py # Twitter GraphQL API Client (GET) ├── auth.py # Cookie 提取 (env / browser-cookie3) ├── filter.py # Engagement scoring + 筛选 ├── formatter.py # Rich 终端输出 + JSON ├── config.py # YAML 配置加载 +├── serialization.py # Tweet JSON <-> dataclass └── models.py # 数据模型 (dataclass) ``` +## Development + +```bash +# Install development tools +uv sync --extra dev + +# Run tests +uv run pytest + +# Lint +uv run ruff check . +``` + ## 注意事项 - 使用 Cookie 登录存在被平台检测的风险,建议使用**专用小号** diff --git a/pyproject.toml b/pyproject.toml index b4ce9b7..c809199 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,13 @@ dependencies = [ "browser-cookie3>=0.19", "click>=8.0", "rich>=13.0", + "PyYAML>=6.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "ruff>=0.8", ] [project.urls] @@ -38,3 +45,10 @@ Issues = "https://github.com/jackwener/twitter-cli/issues" [project.scripts] twitter = "twitter_cli.cli:cli" + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] + +[tool.ruff] +line-length = 100 diff --git a/twitter_cli/auth.py b/twitter_cli/auth.py index e8adac9..8db7c6a 100644 --- a/twitter_cli/auth.py +++ b/twitter_cli/auth.py @@ -13,8 +13,9 @@ import os import ssl import subprocess import sys +import urllib.error import urllib.request -from typing import Any, Dict, Optional +from typing import Dict, Optional logger = logging.getLogger(__name__) @@ -142,7 +143,8 @@ sys.exit(1) if not output: stderr = result.stderr.strip() if stderr: - # Maybe browser-cookie3 not installed, try with uv + logger.debug("Cookie extraction stderr from current env: %s", stderr[:300]) + # Maybe browser-cookie3 not installed, try with uv. result2 = subprocess.run( ["uv", "run", "--with", "browser-cookie3", "python3", "-c", extract_script], capture_output=True, @@ -151,6 +153,7 @@ sys.exit(1) ) output = result2.stdout.strip() if not output: + logger.debug("Cookie extraction stderr from uv fallback: %s", result2.stderr.strip()[:300]) return None data = json.loads(output) @@ -185,4 +188,6 @@ def get_cookies() -> Dict[str, str]: "Option 2: Make sure you are logged into x.com in your browser (Chrome/Edge/Firefox/Brave)" ) + # Verify only for explicit auth failures; transient endpoint issues are tolerated. + verify_cookies(cookies["auth_token"], cookies["ct0"]) return cookies diff --git a/twitter_cli/cli.py b/twitter_cli/cli.py index ea13dbc..6c83f96 100644 --- a/twitter_cli/cli.py +++ b/twitter_cli/cli.py @@ -1,28 +1,24 @@ """CLI entry point for twitter-cli. Usage: - twitter feed # fetch home timeline → filter + twitter feed # fetch home timeline (For You) + twitter feed -t following # fetch following feed twitter feed --max 50 # custom fetch count - twitter feed --no-filter # skip filtering + twitter feed --filter # enable score-based filtering twitter feed --json # JSON output twitter favorite # fetch bookmarks - twitter favorite --max 30 twitter feed --input tweets.json # load existing data twitter feed --output out.json # save filtered tweets - twitter post "Hello" # post a tweet - twitter reply ID "text" # reply to a tweet - twitter quote ID "text" # quote a tweet - twitter delete ID # delete a tweet + twitter user elonmusk # view user profile + twitter user-posts elonmusk # list user tweets """ from __future__ import annotations -import json import logging import sys import time from pathlib import Path -from typing import List import click from rich.console import Console @@ -32,17 +28,12 @@ from .auth import get_cookies from .client import TwitterClient from .config import load_config from .filter import filter_tweets -from .formatter import ( - print_filter_stats, - print_tweet_table, - print_user_profile, - print_user_table, - tweets_to_json, -) -from .models import Author, Metrics, Tweet, TweetMedia +from .formatter import print_filter_stats, print_tweet_table, print_user_profile +from .serialization import tweets_from_json, tweets_to_json console = Console() +FEED_TYPES = ["for-you", "following"] def _setup_logging(verbose): @@ -58,70 +49,49 @@ def _setup_logging(verbose): def _load_tweets_from_json(path): # type: (str) -> List[Tweet] """Load tweets from a JSON file (previously exported).""" - raw = Path(path).read_text(encoding="utf-8") - items = json.loads(raw) - tweets = [] - for d in items: - author_data = d.get("author", {}) - metrics_data = d.get("metrics", {}) - media_data = d.get("media", []) + file_path = Path(path) + if not file_path.exists(): + raise RuntimeError("Input file not found: %s" % path) - author = Author( - id=author_data.get("id", ""), - name=author_data.get("name", ""), - screen_name=author_data.get("screenName", ""), - profile_image_url=author_data.get("profileImageUrl", ""), - verified=author_data.get("verified", False), - ) - metrics = Metrics( - likes=metrics_data.get("likes", 0), - retweets=metrics_data.get("retweets", 0), - replies=metrics_data.get("replies", 0), - quotes=metrics_data.get("quotes", 0), - views=metrics_data.get("views", 0), - bookmarks=metrics_data.get("bookmarks", 0), - ) - media = [ - TweetMedia( - type=m.get("type", ""), - url=m.get("url", ""), - width=m.get("width"), - height=m.get("height"), - ) - for m in media_data - ] + try: + raw = file_path.read_text(encoding="utf-8") + return tweets_from_json(raw) + except (ValueError, OSError) as exc: + raise RuntimeError("Invalid tweet JSON file %s: %s" % (path, exc)) - qt_data = d.get("quotedTweet") - quoted_tweet = None - if qt_data: - qt_author = qt_data.get("author", {}) - quoted_tweet = Tweet( - id=qt_data.get("id", ""), - text=qt_data.get("text", ""), - author=Author( - id="", - name=qt_author.get("name", ""), - screen_name=qt_author.get("screenName", ""), - ), - metrics=Metrics(), - created_at="", - ) - tweets.append(Tweet( - id=d.get("id", ""), - text=d.get("text", ""), - author=author, - metrics=metrics, - created_at=d.get("createdAt", ""), - media=media, - urls=d.get("urls", []), - is_retweet=d.get("isRetweet", False), - lang=d.get("lang", ""), - retweeted_by=d.get("retweetedBy"), - quoted_tweet=quoted_tweet, - score=d.get("score", 0.0), - )) - return tweets +def _get_client(): + # type: () -> TwitterClient + """Create an authenticated API client.""" + console.print("\n🔐 Getting Twitter cookies...") + try: + cookies = get_cookies() + except RuntimeError as exc: + raise RuntimeError(str(exc)) + return TwitterClient(cookies["auth_token"], cookies["ct0"]) + + +def _resolve_fetch_count(max_count, configured): + # type: (Optional[int], int) -> int + """Resolve fetch count with bounds checks.""" + if max_count is not None: + if max_count <= 0: + raise RuntimeError("--max must be greater than 0") + return max_count + return max(configured, 1) + + +def _apply_filter(tweets, do_filter, config): + # type: (List[Tweet], bool, dict) -> List[Tweet] + """Optionally apply tweet filtering.""" + if not do_filter: + return tweets + filter_config = config.get("filter", {}) + original_count = len(tweets) + filtered = filter_tweets(tweets, filter_config) + print_filter_stats(original_count, filtered, console) + console.print() + return filtered @click.group() @@ -133,107 +103,88 @@ def cli(verbose): _setup_logging(verbose) -# ===== Feed ===== - @cli.command() +@click.option( + "--type", + "-t", + "feed_type", + type=click.Choice(FEED_TYPES), + default="for-you", + help="Feed type: for-you (algorithmic) or following (chronological).", +) @click.option("--max", "-n", "max_count", type=int, default=None, help="Max number of tweets to fetch.") @click.option("--json", "as_json", is_flag=True, help="Output as JSON.") @click.option("--input", "-i", "input_file", type=str, default=None, help="Load tweets from JSON file.") @click.option("--output", "-o", "output_file", type=str, default=None, help="Save filtered tweets to JSON file.") -@click.option("--no-filter", is_flag=True, help="Skip filtering.") -def feed(max_count, as_json, input_file, output_file, no_filter): - # type: (int, bool, str, str, bool) -> None - """Fetch home timeline with filtering.""" +@click.option("--filter", "do_filter", is_flag=True, help="Enable score-based filtering.") +def feed(feed_type, max_count, as_json, input_file, output_file, do_filter): + # type: (str, Optional[int], bool, Optional[str], Optional[str], bool) -> None + """Fetch home timeline with optional filtering.""" config = load_config() + try: + if input_file: + console.print("📂 Loading tweets from %s..." % input_file) + tweets = _load_tweets_from_json(input_file) + console.print(" Loaded %d tweets" % len(tweets)) + else: + fetch_count = _resolve_fetch_count(max_count, config.get("fetch", {}).get("count", 50)) + client = _get_client() + label = "following feed" if feed_type == "following" else "home timeline" + console.print("📡 Fetching %s (%d tweets)...\n" % (label, fetch_count)) + start = time.time() + if feed_type == "following": + tweets = client.fetch_following_feed(fetch_count) + else: + tweets = client.fetch_home_timeline(fetch_count) + elapsed = time.time() - start + console.print("✅ Fetched %d tweets in %.1fs\n" % (len(tweets), elapsed)) + except RuntimeError as exc: + console.print("[red]❌ %s[/red]" % exc) + sys.exit(1) - # Step 1: Get tweets - if input_file: - console.print("📂 Loading tweets from %s..." % input_file) - tweets = _load_tweets_from_json(input_file) - console.print(" Loaded %d tweets" % len(tweets)) - else: - fetch_count = max_count or config.get("fetch", {}).get("count", 50) - console.print("\n🔐 Getting Twitter cookies...") - try: - cookies = get_cookies() - except RuntimeError as e: - console.print("[red]❌ %s[/red]" % e) - sys.exit(1) + filtered = _apply_filter(tweets, do_filter, config) - client = TwitterClient(cookies["auth_token"], cookies["ct0"]) - console.print("📡 Fetching home timeline (%d tweets)...\n" % fetch_count) - start = time.time() - tweets = client.fetch_home_timeline(fetch_count) - elapsed = time.time() - start - console.print("✅ Fetched %d tweets in %.1fs\n" % (len(tweets), elapsed)) - - # Step 2: Filter - if no_filter: - filtered = tweets - else: - filter_config = config.get("filter", {}) - original_count = len(tweets) - filtered = filter_tweets(tweets, filter_config) - print_filter_stats(original_count, filtered, console) - console.print() - - # Save filtered tweets if output_file: Path(output_file).write_text(tweets_to_json(filtered), encoding="utf-8") console.print("💾 Saved filtered tweets to %s\n" % output_file) - # Output if as_json: click.echo(tweets_to_json(filtered)) return - print_tweet_table(filtered, console) + title = "👥 Following" if feed_type == "following" else "📱 Twitter" + title += " — %d tweets" % len(filtered) + print_tweet_table(filtered, console, title=title) console.print() -# ===== Favorite ===== - @cli.command() @click.option("--max", "-n", "max_count", type=int, default=None, help="Max number of tweets to fetch.") @click.option("--json", "as_json", is_flag=True, help="Output as JSON.") @click.option("--output", "-o", "output_file", type=str, default=None, help="Save tweets to JSON file.") -@click.option("--no-filter", is_flag=True, help="Skip filtering.") -def favorite(max_count, as_json, output_file, no_filter): - # type: (int, bool, str, bool) -> None +@click.option("--filter", "do_filter", is_flag=True, help="Enable score-based filtering.") +def favorite(max_count, as_json, output_file, do_filter): + # type: (Optional[int], bool, Optional[str], bool) -> None """Fetch bookmarked (favorite) tweets.""" config = load_config() - fetch_count = max_count or 50 - - console.print("\n🔐 Getting Twitter cookies...") try: - cookies = get_cookies() - except RuntimeError as e: - console.print("[red]❌ %s[/red]" % e) + fetch_count = _resolve_fetch_count(max_count, config.get("fetch", {}).get("count", 50)) + client = _get_client() + console.print("🔖 Fetching favorites (%d tweets)...\n" % fetch_count) + start = time.time() + tweets = client.fetch_bookmarks(fetch_count) + elapsed = time.time() - start + console.print("✅ Fetched %d favorites in %.1fs\n" % (len(tweets), elapsed)) + except RuntimeError as exc: + console.print("[red]❌ %s[/red]" % exc) sys.exit(1) - client = TwitterClient(cookies["auth_token"], cookies["ct0"]) - console.print("🔖 Fetching favorites (%d tweets)...\n" % fetch_count) - start = time.time() - tweets = client.fetch_bookmarks(fetch_count) - elapsed = time.time() - start - console.print("✅ Fetched %d favorites in %.1fs\n" % (len(tweets), elapsed)) + filtered = _apply_filter(tweets, do_filter, config) - # Filter - if no_filter: - filtered = tweets - else: - filter_config = config.get("filter", {}) - original_count = len(tweets) - filtered = filter_tweets(tweets, filter_config) - print_filter_stats(original_count, filtered, console) - console.print() - - # Save if output_file: Path(output_file).write_text(tweets_to_json(filtered), encoding="utf-8") console.print("💾 Saved to %s\n" % output_file) - # Output if as_json: click.echo(tweets_to_json(filtered)) return @@ -242,24 +193,23 @@ def favorite(max_count, as_json, output_file, no_filter): console.print() -# ===== User ===== - @cli.command() @click.argument("screen_name") def user(screen_name): # type: (str,) -> None """View a user's profile. SCREEN_NAME is the @handle (without @).""" screen_name = screen_name.lstrip("@") - client = _get_client() - console.print("👤 Fetching user @%s..." % screen_name) try: + client = _get_client() + console.print("👤 Fetching user @%s..." % screen_name) profile = client.fetch_user(screen_name) - console.print() - print_user_profile(profile, console) - except RuntimeError as e: - console.print("[red]❌ %s[/red]" % e) + except RuntimeError as exc: + console.print("[red]❌ %s[/red]" % exc) sys.exit(1) + console.print() + print_user_profile(profile, console) + @cli.command("user-posts") @click.argument("screen_name") @@ -269,24 +219,20 @@ def user_posts(screen_name, max_count, as_json): # type: (str, int, bool) -> None """List a user's tweets. SCREEN_NAME is the @handle (without @).""" screen_name = screen_name.lstrip("@") - client = _get_client() - console.print("👤 Fetching @%s's profile..." % screen_name) try: + fetch_count = _resolve_fetch_count(max_count, 20) + client = _get_client() + console.print("👤 Fetching @%s's profile..." % screen_name) profile = client.fetch_user(screen_name) - except RuntimeError as e: - console.print("[red]❌ %s[/red]" % e) + console.print("📝 Fetching tweets (%d)...\n" % fetch_count) + start = time.time() + tweets = client.fetch_user_tweets(profile.id, fetch_count) + elapsed = time.time() - start + console.print("✅ Fetched %d tweets in %.1fs\n" % (len(tweets), elapsed)) + except RuntimeError as exc: + console.print("[red]❌ %s[/red]" % exc) sys.exit(1) - console.print("📝 Fetching tweets (%d)...\n" % max_count) - start = time.time() - try: - tweets = client.fetch_user_tweets(profile.id, max_count) - except RuntimeError as e: - console.print("[red]❌ %s[/red]" % e) - sys.exit(1) - elapsed = time.time() - start - console.print("✅ Fetched %d tweets in %.1fs\n" % (len(tweets), elapsed)) - if as_json: click.echo(tweets_to_json(tweets)) return @@ -295,148 +241,5 @@ def user_posts(screen_name, max_count, as_json): console.print() -@cli.command() -@click.argument("screen_name") -@click.option("--max", "-n", "max_count", type=int, default=20, help="Max number of users to show.") -def followers(screen_name, max_count): - # type: (str, int) -> None - """List a user's followers. SCREEN_NAME is the @handle (without @).""" - screen_name = screen_name.lstrip("@") - client = _get_client() - console.print("👤 Fetching @%s's profile..." % screen_name) - try: - profile = client.fetch_user(screen_name) - except RuntimeError as e: - console.print("[red]❌ %s[/red]" % e) - sys.exit(1) - - console.print("👥 Fetching followers...\n") - try: - users = client.fetch_followers(profile.id, max_count) - except RuntimeError as e: - console.print("[red]❌ %s[/red]" % e) - sys.exit(1) - print_user_table(users, console, title="👥 @%s's followers — %d" % (screen_name, len(users))) - console.print() - - -@cli.command() -@click.argument("screen_name") -@click.option("--max", "-n", "max_count", type=int, default=20, help="Max number of users to show.") -def following(screen_name, max_count): - # type: (str, int) -> None - """List users that someone follows. SCREEN_NAME is the @handle (without @).""" - screen_name = screen_name.lstrip("@") - client = _get_client() - console.print("👤 Fetching @%s's profile..." % screen_name) - try: - profile = client.fetch_user(screen_name) - except RuntimeError as e: - console.print("[red]❌ %s[/red]" % e) - sys.exit(1) - - console.print("👥 Fetching following...\n") - try: - users = client.fetch_following(profile.id, max_count) - except RuntimeError as e: - console.print("[red]❌ %s[/red]" % e) - sys.exit(1) - print_user_table(users, console, title="👥 @%s follows — %d" % (screen_name, len(users))) - console.print() - - -# ===== Post / Reply / Quote / Delete ===== - -def _get_client(): - # type: () -> TwitterClient - """Helper to authenticate and create a TwitterClient.""" - console.print("\n🔐 Getting Twitter cookies...") - try: - cookies = get_cookies() - except RuntimeError as e: - console.print("[red]❌ %s[/red]" % e) - sys.exit(1) - return TwitterClient(cookies["auth_token"], cookies["ct0"]) - - -@cli.command() -@click.argument("text") -def post(text): - # type: (str,) -> None - """Post a new tweet.""" - client = _get_client() - console.print("✏️ Posting tweet...") - try: - result = client.create_tweet(text) - tweet_id = result["tweet_id"] - console.print("\n[green]✅ Tweet posted![/green]") - console.print(" ID: %s" % tweet_id) - console.print(" URL: https://x.com/i/status/%s" % tweet_id) - console.print(' Text: "%s"' % result["text"][:100]) - except RuntimeError as e: - console.print("[red]❌ %s[/red]" % e) - sys.exit(1) - - -@cli.command() -@click.argument("tweet_id") -@click.argument("text") -def reply(tweet_id, text): - # type: (str, str) -> None - """Reply to a tweet.""" - client = _get_client() - console.print("💬 Replying to %s..." % tweet_id) - try: - result = client.create_tweet(text, reply_to=tweet_id) - new_id = result["tweet_id"] - console.print("\n[green]✅ Reply posted![/green]") - console.print(" ID: %s" % new_id) - console.print(" URL: https://x.com/i/status/%s" % new_id) - console.print(' Text: "%s"' % result["text"][:100]) - except RuntimeError as e: - console.print("[red]❌ %s[/red]" % e) - sys.exit(1) - - -@cli.command() -@click.argument("tweet_url") -@click.argument("text") -def quote(tweet_url, text): - # type: (str, str) -> None - """Quote a tweet. TWEET_URL can be a full URL or tweet ID.""" - # If user passes just an ID, convert to URL - if not tweet_url.startswith("http"): - tweet_url = "https://x.com/i/status/%s" % tweet_url - client = _get_client() - console.print("🔄 Quoting %s..." % tweet_url) - try: - result = client.create_tweet(text, quote_tweet_url=tweet_url) - new_id = result["tweet_id"] - console.print("\n[green]✅ Quote tweet posted![/green]") - console.print(" ID: %s" % new_id) - console.print(" URL: https://x.com/i/status/%s" % new_id) - console.print(' Text: "%s"' % result["text"][:100]) - except RuntimeError as e: - console.print("[red]❌ %s[/red]" % e) - sys.exit(1) - - -@cli.command() -@click.argument("tweet_id") -@click.confirmation_option(prompt="Are you sure you want to delete this tweet?") -def delete(tweet_id): - # type: (str,) -> None - """Delete a tweet by ID.""" - client = _get_client() - console.print("🗑️ Deleting tweet %s..." % tweet_id) - try: - client.delete_tweet(tweet_id) - console.print("\n[green]✅ Tweet deleted![/green]") - console.print(" ID: %s" % tweet_id) - except RuntimeError as e: - console.print("[red]❌ %s[/red]" % e) - sys.exit(1) - - if __name__ == "__main__": cli() diff --git a/twitter_cli/client.py b/twitter_cli/client.py index 76a4ed7..fd208b3 100644 --- a/twitter_cli/client.py +++ b/twitter_cli/client.py @@ -1,9 +1,4 @@ -"""Twitter GraphQL API client. - -Uses the same internal GraphQL endpoint that the Twitter web app uses, -authenticated via cookies (auth_token + ct0). QueryId is resolved -dynamically using a three-tier strategy. -""" +"""Twitter GraphQL API client.""" from __future__ import annotations @@ -12,38 +7,33 @@ import logging import math import re import ssl +import urllib.error +import urllib.parse import urllib.request -from typing import Any, Callable, Dict, List, Optional, Tuple from .models import Author, Metrics, Tweet, TweetMedia, UserProfile logger = logging.getLogger(__name__) -# Public bearer token shared by all Twitter web clients + BEARER_TOKEN = ( "AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs" "%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA" ) -# Last-resort fallback query IDs FALLBACK_QUERY_IDS = { "HomeTimeline": "c-CzHF1LboFilMpsx4ZCrQ", + "HomeLatestTimeline": "BKB7oi212Fi7kQtCBGE4zA", "Bookmarks": "VFdMm9iVZxlU6hD86gfW_A", - "CreateTweet": "oB-5XsHNAbjvARJEc8CZFw", - "DeleteTweet": "VaenaVgh5q5ih7kvyVjgtg", "UserByScreenName": "1VOOyvKkiI3FMmkeDNxM9A", "UserTweets": "E3opETHurmVJflFsUBVuUQ", - "Followers": "IOh4aS6UdGWGJUYTqliQ7Q", - "Following": "zx6e-TLzRkeDO_a7p4b3JQ", } -# Community-maintained API definition (auto-updated daily) TWITTER_OPENAPI_URL = ( "https://raw.githubusercontent.com/fa0311/twitter-openapi/" "main/src/config/placeholder.json" ) -# Default features flags required by the GraphQL endpoint FEATURES = { "rweb_video_screen_enabled": False, "profile_label_improvements_pcf_label_in_post_enabled": True, @@ -84,33 +74,50 @@ USER_AGENT = ( "Chrome/131.0.0.0 Safari/537.36" ) -# Module-level cache for query IDs _cached_query_ids = {} # type: Dict[str, str] _bundles_scanned = False +class TwitterAPIError(RuntimeError): + """Represents HTTP/network errors from Twitter APIs.""" + + def __init__(self, status_code, message): + # type: (int, str) -> None + super().__init__(message) + self.status_code = status_code + + def _create_ssl_context(): # type: () -> ssl.SSLContext - """Create a permissive SSL context for urllib.""" - ctx = ssl.create_default_context() - return ctx + """Create SSL context for urllib.""" + return ssl.create_default_context() def _url_fetch(url, headers=None): # type: (str, Optional[Dict[str, str]]) -> str - """Simple URL fetch using urllib.""" + """Simple URL fetch for metadata/bootstrap lookups.""" req = urllib.request.Request(url) if headers: - for k, v in headers.items(): - req.add_header(k, v) - ctx = _create_ssl_context() - with urllib.request.urlopen(req, context=ctx, timeout=30) as resp: - return resp.read().decode("utf-8") + for key, value in headers.items(): + req.add_header(key, value) + with urllib.request.urlopen(req, context=_create_ssl_context(), timeout=30) as response: + return response.read().decode("utf-8") + + +def _build_graphql_url(query_id, operation_name, variables, features): + # type: (str, str, Dict[str, Any], Dict[str, Any]) -> str + """Build GraphQL GET URL with encoded variables/features.""" + return "https://x.com/i/api/graphql/%s/%s?variables=%s&features=%s" % ( + query_id, + operation_name, + urllib.parse.quote(json.dumps(variables, separators=(",", ":"))), + urllib.parse.quote(json.dumps(features, separators=(",", ":"))), + ) def _scan_bundles(): # type: () -> None - """Tier 1: Scan Twitter's main-page JS bundles to extract queryId/operationName pairs.""" + """Scan Twitter JS bundles and cache queryId mappings.""" global _bundles_scanned if _bundles_scanned: return @@ -118,82 +125,80 @@ def _scan_bundles(): try: html = _url_fetch("https://x.com", {"user-agent": USER_AGENT}) - script_pattern = re.compile( r'(?:src|href)=["\']' r'(https://abs\.twimg\.com/responsive-web/client-web[^"\']+\.js)' r'["\']' ) script_urls = script_pattern.findall(html) + except Exception as exc: # pragma: no cover - network-dependent branch + logger.warning("Failed to scan JS bundles: %s", exc) + return - for url in script_urls: - try: - js = _url_fetch(url) - op_pattern = re.compile( - r'queryId:\s*"([A-Za-z0-9_-]+)"[^}]{0,200}' - r'operationName:\s*"([^"]+)"' - ) - for m in op_pattern.finditer(js): - qid, name = m.group(1), m.group(2) - if name not in _cached_query_ids: - _cached_query_ids[name] = qid - except Exception: - continue + for script_url in script_urls: + try: + bundle = _url_fetch(script_url) + op_pattern = re.compile( + r'queryId:\s*"([A-Za-z0-9_-]+)"[^}]{0,200}' + r'operationName:\s*"([^"]+)"' + ) + for match in op_pattern.finditer(bundle): + query_id, operation_name = match.group(1), match.group(2) + _cached_query_ids.setdefault(operation_name, query_id) + except Exception: + continue - count = len(_cached_query_ids) - logger.info("Scanned %d JS bundles, found %d operations", len(script_urls), count) - except Exception as e: - logger.warning("Failed to scan JS bundles: %s", e) + logger.info("Scanned %d JS bundles, cached %d query IDs", len(script_urls), len(_cached_query_ids)) def _fetch_from_github(operation_name): # type: (str) -> Optional[str] - """Tier 2: Fetch queryId from community-maintained twitter-openapi.""" + """Fetch queryId from community-maintained twitter-openapi file.""" try: - logger.info("Fetching latest queryId from GitHub (twitter-openapi)...") - data_str = _url_fetch(TWITTER_OPENAPI_URL) - data = json.loads(data_str) - op = data.get(operation_name, {}) - qid = op.get("queryId") - if qid: - logger.info("Found %s queryId from GitHub: %s", operation_name, qid) - return qid - return None - except Exception as e: - logger.warning("GitHub lookup failed: %s", e) - return None + payload = _url_fetch(TWITTER_OPENAPI_URL) + parsed = json.loads(payload) + operation = parsed.get(operation_name, {}) + query_id = operation.get("queryId") + if isinstance(query_id, str) and query_id: + return query_id + except Exception as exc: # pragma: no cover - network-dependent branch + logger.debug("GitHub queryId lookup failed: %s", exc) + return None -def _resolve_query_id(operation_name): - # type: (str) -> str - """Resolve queryId using three-tier strategy: fallback -> GitHub -> bundle scan.""" - if operation_name in _cached_query_ids: - return _cached_query_ids[operation_name] +def _invalidate_query_id(operation_name): + # type: (str) -> None + """Remove a cached queryId for an operation.""" + _cached_query_ids.pop(operation_name, None) + + +def _resolve_query_id(operation_name, prefer_fallback=True): + # type: (str, bool) -> str + """Resolve queryId using cache, remote sources, and fallback constants.""" + cached = _cached_query_ids.get(operation_name) + if cached: + return cached - # Tier 1: Hardcoded fallback (instant, no network) fallback = FALLBACK_QUERY_IDS.get(operation_name) - if fallback: - logger.debug("Using fallback queryId for %s: %s", operation_name, fallback) + if prefer_fallback and fallback: _cached_query_ids[operation_name] = fallback return fallback - logger.info("Auto-detecting %s queryId (no fallback available)...", operation_name) + github_query_id = _fetch_from_github(operation_name) + if github_query_id: + _cached_query_ids[operation_name] = github_query_id + return github_query_id - # Tier 2: GitHub - github_id = _fetch_from_github(operation_name) - if github_id: - _cached_query_ids[operation_name] = github_id - return github_id - - # Tier 3: JS bundle scan _scan_bundles() - if operation_name in _cached_query_ids: - logger.info("Found %s queryId: %s", operation_name, _cached_query_ids[operation_name]) - return _cached_query_ids[operation_name] + cached = _cached_query_ids.get(operation_name) + if cached: + return cached - raise RuntimeError( - 'Cannot resolve queryId for "%s" — all detection methods failed' % operation_name - ) + if fallback: + _cached_query_ids[operation_name] = fallback + return fallback + + raise RuntimeError('Cannot resolve queryId for "%s"' % operation_name) class TwitterClient: @@ -207,230 +212,36 @@ class TwitterClient: def fetch_home_timeline(self, count=20): # type: (int) -> List[Tweet] """Fetch home timeline tweets.""" - query_id = _resolve_query_id("HomeTimeline") return self._fetch_timeline( - query_id, "HomeTimeline", count, lambda data: _deep_get(data, "data", "home", "home_timeline_urt", "instructions"), ) + def fetch_following_feed(self, count=20): + # type: (int) -> List[Tweet] + """Fetch chronological following feed.""" + return self._fetch_timeline( + "HomeLatestTimeline", + count, + lambda data: _deep_get(data, "data", "home", "home_timeline_urt", "instructions"), + ) + def fetch_bookmarks(self, count=50): # type: (int) -> List[Tweet] """Fetch bookmarked tweets.""" - query_id = _resolve_query_id("Bookmarks") - def get_instructions(data): # type: (Any) -> Any - result = _deep_get(data, "data", "bookmark_timeline", "timeline", "instructions") - if result is None: - result = _deep_get(data, "data", "bookmark_timeline_v2", "timeline", "instructions") - return result + instructions = _deep_get(data, "data", "bookmark_timeline", "timeline", "instructions") + if instructions is None: + instructions = _deep_get(data, "data", "bookmark_timeline_v2", "timeline", "instructions") + return instructions - return self._fetch_timeline(query_id, "Bookmarks", count, get_instructions) - - def _fetch_timeline(self, query_id, operation_name, count, get_instructions, extra_variables=None): - # type: (str, str, int, Callable, Optional[Dict[str, Any]]) -> List[Tweet] - """Generic timeline fetcher with pagination and deduplication.""" - tweets = [] # type: List[Tweet] - cursor = None # type: Optional[str] - attempts = 0 - max_attempts = int(math.ceil(count / 20.0)) + 2 - - while len(tweets) < count and attempts < max_attempts: - attempts += 1 - variables = { - "count": min(count - len(tweets) + 5, 40), - "includePromotedContent": False, - "latestControlAvailable": True, - "requestContext": "launch", - } # type: Dict[str, Any] - - if extra_variables: - variables.update(extra_variables) - if cursor: - variables["cursor"] = cursor - - url = "https://x.com/i/api/graphql/%s/%s?" % (query_id, operation_name) - url += "variables=%s&features=%s" % ( - urllib.request.quote(json.dumps(variables)), - urllib.request.quote(json.dumps(FEATURES)), - ) - - data = self._api_get(url) - new_tweets, next_cursor = self._parse_timeline_response(data, get_instructions) - - seen_ids = {t.id for t in tweets} - for tweet in new_tweets: - if tweet.id not in seen_ids: - tweets.append(tweet) - seen_ids.add(tweet.id) - - if not next_cursor or not new_tweets: - break - cursor = next_cursor - - return tweets[:count] - - def _build_headers(self): - # type: () -> Dict[str, str] - return { - "Authorization": "Bearer %s" % BEARER_TOKEN, - "Cookie": "auth_token=%s; ct0=%s" % (self._auth_token, self._ct0), - "X-Csrf-Token": self._ct0, - "X-Twitter-Active-User": "yes", - "X-Twitter-Auth-Type": "OAuth2Session", - "X-Twitter-Client-Language": "en", - "Content-Type": "application/json", - "User-Agent": USER_AGENT, - "Referer": "https://x.com/home", - "Accept": "*/*", - "Accept-Language": "en-US,en;q=0.9", - } - - def _api_get(self, url): - # type: (str) -> Any - """Make authenticated GET request to Twitter API.""" - headers = self._build_headers() - req = urllib.request.Request(url) - for k, v in headers.items(): - req.add_header(k, v) - - ctx = _create_ssl_context() - try: - with urllib.request.urlopen(req, context=ctx, timeout=30) as resp: - body = resp.read().decode("utf-8") - return json.loads(body) - except urllib.error.HTTPError as e: - body = e.read().decode("utf-8", errors="replace") - raise RuntimeError("Twitter API error %d: %s" % (e.code, body[:500])) - - def _api_post(self, url, payload): - # type: (str, Dict[str, Any]) -> Any - """Make authenticated POST request to Twitter API.""" - headers = self._build_headers() - data = json.dumps(payload).encode("utf-8") - req = urllib.request.Request(url, data=data, method="POST") - for k, v in headers.items(): - req.add_header(k, v) - - ctx = _create_ssl_context() - try: - with urllib.request.urlopen(req, context=ctx, timeout=30) as resp: - body = resp.read().decode("utf-8") - return json.loads(body) - except urllib.error.HTTPError as e: - body = e.read().decode("utf-8", errors="replace") - raise RuntimeError("Twitter API error %d: %s" % (e.code, body[:500])) - - def create_tweet(self, text, reply_to=None, quote_tweet_url=None): - # type: (str, Optional[str], Optional[str]) -> Dict[str, Any] - """Create a tweet, reply, or quote tweet. - - Args: - text: Tweet text content. - reply_to: Tweet ID to reply to (optional). - quote_tweet_url: URL of tweet to quote (optional). - - Returns: - Dict with tweet_id and text of the created tweet. - """ - query_id = _resolve_query_id("CreateTweet") - url = "https://x.com/i/api/graphql/%s/CreateTweet" % query_id - - variables = { - "tweet_text": text, - "dark_request": False, - "media": {"media_entities": [], "possibly_sensitive": False}, - "semantic_annotation_ids": [], - } # type: Dict[str, Any] - - if reply_to: - variables["reply"] = { - "in_reply_to_tweet_id": reply_to, - "exclude_reply_user_ids": [], - } - - if quote_tweet_url: - variables["attachment_url"] = quote_tweet_url - - features = { - "communities_web_enable_tweet_community_results_fetch": True, - "c9s_tweet_anatomy_moderator_badge_enabled": True, - "responsive_web_edit_tweet_api_enabled": True, - "graphql_is_translatable_rweb_tweet_is_translatable_enabled": True, - "view_counts_everywhere_api_enabled": True, - "longform_notetweets_consumption_enabled": True, - "responsive_web_twitter_article_tweet_consumption_enabled": True, - "tweet_awards_web_tipping_enabled": False, - "creator_subscriptions_quote_tweet_preview_enabled": False, - "longform_notetweets_rich_text_read_enabled": True, - "longform_notetweets_inline_media_enabled": True, - "articles_preview_enabled": True, - "rweb_video_timestamps_enabled": True, - "rweb_tipjar_consumption_enabled": True, - "responsive_web_graphql_exclude_directive_enabled": True, - "verified_phone_label_enabled": False, - "freedom_of_speech_not_reach_fetch_enabled": True, - "standardized_nudges_misinfo": True, - "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": True, - "responsive_web_graphql_skip_user_profile_image_extensions_enabled": False, - "responsive_web_graphql_timeline_navigation_enabled": True, - "responsive_web_enhance_cards_enabled": False, - } - - payload = { - "variables": variables, - "features": features, - "queryId": query_id, - } - - data = self._api_post(url, payload) - - # Parse response - result = _deep_get(data, "data", "create_tweet", "tweet_results", "result") - if not result: - errors = data.get("errors", []) - if errors: - raise RuntimeError("CreateTweet failed: %s" % errors[0].get("message", str(errors))) - raise RuntimeError("CreateTweet failed: unexpected response") - - tweet_id = result.get("rest_id", "") - tweet_text = _deep_get(result, "legacy", "full_text") or text - return {"tweet_id": tweet_id, "text": tweet_text} - - def delete_tweet(self, tweet_id): - # type: (str) -> bool - """Delete a tweet by ID. - - Returns: - True if deletion was successful. - """ - query_id = _resolve_query_id("DeleteTweet") - url = "https://x.com/i/api/graphql/%s/DeleteTweet" % query_id - - payload = { - "variables": {"tweet_id": tweet_id, "dark_request": False}, - "queryId": query_id, - } - - data = self._api_post(url, payload) - - # Check response - result = _deep_get(data, "data", "delete_tweet", "tweet_results") - if result is not None: - return True - - errors = data.get("errors", []) - if errors: - raise RuntimeError("DeleteTweet failed: %s" % errors[0].get("message", str(errors))) - # Some successful deletions return empty result - return True + return self._fetch_timeline("Bookmarks", count, get_instructions) def fetch_user(self, screen_name): # type: (str) -> UserProfile """Fetch user profile by screen name.""" - query_id = _resolve_query_id("UserByScreenName") variables = { "screen_name": screen_name, "withSafetyModeUserFields": True, @@ -449,14 +260,7 @@ class TwitterClient: "responsive_web_graphql_skip_user_profile_image_extensions_enabled": False, "responsive_web_graphql_timeline_navigation_enabled": True, } - - url = "https://x.com/i/api/graphql/%s/UserByScreenName?" % query_id - url += "variables=%s&features=%s" % ( - urllib.request.quote(json.dumps(variables)), - urllib.request.quote(json.dumps(features)), - ) - - data = self._api_get(url) + data = self._graphql_get("UserByScreenName", variables, features) result = _deep_get(data, "data", "user", "result") if not result: raise RuntimeError("User @%s not found" % screen_name) @@ -474,10 +278,10 @@ class TwitterClient: if legacy.get("entities", {}).get("url") else "" ), - followers_count=legacy.get("followers_count", 0), - following_count=legacy.get("friends_count", 0), - tweets_count=legacy.get("statuses_count", 0), - likes_count=legacy.get("favourites_count", 0), + followers_count=_to_int(legacy.get("followers_count"), 0), + following_count=_to_int(legacy.get("friends_count"), 0), + tweets_count=_to_int(legacy.get("statuses_count"), 0), + likes_count=_to_int(legacy.get("favourites_count"), 0), verified=bool(result.get("is_blue_verified") or legacy.get("verified", False)), profile_image_url=legacy.get("profile_image_url_https", ""), created_at=legacy.get("created_at", ""), @@ -486,9 +290,7 @@ class TwitterClient: def fetch_user_tweets(self, user_id, count=20): # type: (str, int) -> List[Tweet] """Fetch tweets posted by a user.""" - query_id = _resolve_query_id("UserTweets") return self._fetch_timeline( - query_id, "UserTweets", count, lambda data: _deep_get(data, "data", "user", "result", "timeline_v2", "timeline", "instructions"), @@ -500,239 +302,285 @@ class TwitterClient: }, ) - def fetch_followers(self, user_id, count=20): - # type: (str, int) -> List[UserProfile] - """Fetch user's followers.""" - query_id = _resolve_query_id("Followers") - return self._fetch_user_list(query_id, "Followers", user_id, count) + def _fetch_timeline(self, operation_name, count, get_instructions, extra_variables=None): + # type: (str, int, Callable[[Any], Any], Optional[Dict[str, Any]]) -> List[Tweet] + """Generic timeline fetcher with pagination and deduplication.""" + if count <= 0: + return [] - def fetch_following(self, user_id, count=20): - # type: (str, int) -> List[UserProfile] - """Fetch users that this user follows.""" - query_id = _resolve_query_id("Following") - return self._fetch_user_list(query_id, "Following", user_id, count) + tweets = [] # type: List[Tweet] + seen_ids = set() # type: Set[str] + cursor = None # type: Optional[str] + attempts = 0 + max_attempts = int(math.ceil(count / 20.0)) + 2 - def _fetch_user_list(self, query_id, operation_name, user_id, count): - # type: (str, str, str, int) -> List[UserProfile] - """Generic user list fetcher (followers/following).""" - variables = { - "userId": user_id, - "count": min(count, 50), - "includePromotedContent": False, - } # type: Dict[str, Any] + while len(tweets) < count and attempts < max_attempts: + attempts += 1 + variables = { + "count": min(count - len(tweets) + 5, 40), + "includePromotedContent": False, + "latestControlAvailable": True, + "requestContext": "launch", + } # type: Dict[str, Any] + if extra_variables: + variables.update(extra_variables) + if cursor: + variables["cursor"] = cursor - url = "https://x.com/i/api/graphql/%s/%s?" % (query_id, operation_name) - url += "variables=%s&features=%s" % ( - urllib.request.quote(json.dumps(variables)), - urllib.request.quote(json.dumps(FEATURES)), - ) + data = self._graphql_get(operation_name, variables, FEATURES) + new_tweets, next_cursor = self._parse_timeline_response(data, get_instructions) - data = self._api_get(url) - users = [] # type: List[UserProfile] + for tweet in new_tweets: + if tweet.id and tweet.id not in seen_ids: + seen_ids.add(tweet.id) + tweets.append(tweet) - instructions = _deep_get(data, "data", "user", "result", "timeline", "timeline", "instructions") - if not isinstance(instructions, list): - return users + if not next_cursor or not new_tweets: + break + cursor = next_cursor - for instruction in instructions: - entries = instruction.get("entries", []) - for entry in entries: - content = entry.get("content", {}) - item_content = content.get("itemContent", {}) - user_results = item_content.get("user_results", {}).get("result") - if not user_results: - continue - legacy = user_results.get("legacy", {}) - core = user_results.get("core", {}) - if not legacy: - continue - users.append(UserProfile( - id=user_results.get("rest_id", ""), - name=core.get("name") or legacy.get("name", ""), - screen_name=core.get("screen_name") or legacy.get("screen_name", ""), - bio=legacy.get("description", ""), - followers_count=legacy.get("followers_count", 0), - following_count=legacy.get("friends_count", 0), - verified=bool(user_results.get("is_blue_verified") or legacy.get("verified", False)), - profile_image_url=legacy.get("profile_image_url_https", ""), - )) + return tweets[:count] - return users[:count] + def _graphql_get(self, operation_name, variables, features): + # type: (str, Dict[str, Any], Dict[str, Any]) -> Dict[str, Any] + """Issue GraphQL GET request with automatic stale-fallback retry.""" + query_id = _resolve_query_id(operation_name, prefer_fallback=True) + using_fallback = query_id == FALLBACK_QUERY_IDS.get(operation_name) + url = _build_graphql_url(query_id, operation_name, variables, features) + + try: + return self._api_get(url) + except TwitterAPIError as exc: + # Fallback query IDs can go stale. Retry with live lookup if 404. + if exc.status_code == 404 and using_fallback: + logger.info("Retrying %s with live queryId after 404", operation_name) + _invalidate_query_id(operation_name) + refreshed_query_id = _resolve_query_id(operation_name, prefer_fallback=False) + retry_url = _build_graphql_url(refreshed_query_id, operation_name, variables, features) + return self._api_get(retry_url) + raise RuntimeError(str(exc)) + + def _build_headers(self): + # type: () -> Dict[str, str] + """Build shared headers for authenticated API calls.""" + return { + "Authorization": "Bearer %s" % BEARER_TOKEN, + "Cookie": "auth_token=%s; ct0=%s" % (self._auth_token, self._ct0), + "X-Csrf-Token": self._ct0, + "X-Twitter-Active-User": "yes", + "X-Twitter-Auth-Type": "OAuth2Session", + "X-Twitter-Client-Language": "en", + "Content-Type": "application/json", + "User-Agent": USER_AGENT, + "Referer": "https://x.com/home", + "Accept": "*/*", + "Accept-Language": "en-US,en;q=0.9", + } + + def _api_get(self, url): + # type: (str) -> Dict[str, Any] + """Make authenticated GET request to Twitter API.""" + headers = self._build_headers() + request = urllib.request.Request(url) + for key, value in headers.items(): + request.add_header(key, value) + + try: + with urllib.request.urlopen(request, context=_create_ssl_context(), timeout=30) as response: + payload = response.read().decode("utf-8") + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + message = "Twitter API error %d: %s" % (exc.code, body[:500]) + raise TwitterAPIError(exc.code, message) + except urllib.error.URLError as exc: + raise TwitterAPIError(0, "Twitter API network error: %s" % exc.reason) + + try: + parsed = json.loads(payload) + except json.JSONDecodeError: + raise TwitterAPIError(0, "Twitter API returned invalid JSON") + + if isinstance(parsed, dict) and parsed.get("errors"): + message = parsed["errors"][0].get("message", "Unknown error") + raise TwitterAPIError(0, "Twitter API returned errors: %s" % message) + return parsed def _parse_timeline_response(self, data, get_instructions): - # type: (Any, Callable) -> Tuple[List[Tweet], Optional[str]] - """Parse timeline GraphQL response into tweets + next cursor.""" + # type: (Any, Callable[[Any], Any]) -> Tuple[List[Tweet], Optional[str]] + """Parse timeline GraphQL response into tweets and next cursor.""" tweets = [] # type: List[Tweet] next_cursor = None # type: Optional[str] - try: - instructions = get_instructions(data) - if not isinstance(instructions, list): - logger.warning("No instructions found in response") - return tweets, next_cursor + instructions = get_instructions(data) + if not isinstance(instructions, list): + logger.warning("No timeline instructions found") + return tweets, next_cursor - for instruction in instructions: - entries = instruction.get("entries") or instruction.get("moduleItems") or [] + for instruction in instructions: + entries = instruction.get("entries") or instruction.get("moduleItems") or [] + for entry in entries: + content = entry.get("content", {}) + next_cursor = _extract_cursor(content) or next_cursor - for entry in entries: - content = entry.get("content", {}) + item_content = content.get("itemContent", {}) + result = _deep_get(item_content, "tweet_results", "result") + if result: + tweet = self._parse_tweet_result(result) + if tweet: + tweets.append(tweet) - # Handle cursor entries - if content.get("cursorType") == "Bottom" or content.get("entryType") == "TimelineTimelineCursor": - val = content.get("value") - if val: - next_cursor = val - continue - - # Handle single tweet entries - item_content = content.get("itemContent", {}) - tweet_results = item_content.get("tweet_results", {}) - result = tweet_results.get("result") - if result: - tweet = self._parse_tweet_result(result) + for nested_item in content.get("items", []): + nested_result = _deep_get( + nested_item, + "item", + "itemContent", + "tweet_results", + "result", + ) + if nested_result: + tweet = self._parse_tweet_result(nested_result) if tweet: tweets.append(tweet) - # Handle conversation module (tweet threads) - items = content.get("items", []) - for item in items: - nested = ( - item.get("item", {}) - .get("itemContent", {}) - .get("tweet_results", {}) - .get("result") - ) - if nested: - tweet = self._parse_tweet_result(nested) - if tweet: - tweets.append(tweet) - except Exception as e: - logger.warning("Error parsing timeline response: %s", e) - return tweets, next_cursor - def _parse_tweet_result(self, result): - # type: (Dict[str, Any]) -> Optional[Tweet] - """Parse a single TweetResult from GraphQL response.""" - try: - tweet_data = result + def _parse_tweet_result(self, result, depth=0): + # type: (Dict[str, Any], int) -> Optional[Tweet] + """Parse a single TweetResult into a Tweet dataclass.""" + if depth > 2: + return None - # Handle TweetWithVisibilityResults wrapper - if result.get("__typename") == "TweetWithVisibilityResults" and result.get("tweet"): - tweet_data = result["tweet"] + tweet_data = result + if result.get("__typename") == "TweetWithVisibilityResults" and result.get("tweet"): + tweet_data = result["tweet"] + if tweet_data.get("__typename") == "TweetTombstone": + return None - if tweet_data.get("__typename") == "TweetTombstone": - return None - if not tweet_data.get("legacy") or not tweet_data.get("core"): - return None + legacy = tweet_data.get("legacy") + core = tweet_data.get("core") + if not isinstance(legacy, dict) or not isinstance(core, dict): + return None - legacy = tweet_data["legacy"] - user = tweet_data["core"]["user_results"]["result"] - user_legacy = user.get("legacy", {}) - user_core = user.get("core", {}) + user = _deep_get(core, "user_results", "result") or {} + user_legacy = user.get("legacy", {}) + user_core = user.get("core", {}) - # Check if this is a retweet - is_retweet = bool(legacy.get("retweeted_status_result", {}).get("result")) - actual_data = tweet_data - actual_legacy = legacy - actual_user = user - actual_user_legacy = user_legacy + is_retweet = bool(_deep_get(legacy, "retweeted_status_result", "result")) + actual_data = tweet_data + actual_legacy = legacy + actual_user = user + actual_user_legacy = user_legacy - if is_retweet: - rt_result = legacy["retweeted_status_result"]["result"] - # Handle wrapped retweet - if rt_result.get("__typename") == "TweetWithVisibilityResults" and rt_result.get("tweet"): - rt_result = rt_result["tweet"] - if rt_result.get("legacy") and rt_result.get("core"): - actual_data = rt_result - actual_legacy = rt_result["legacy"] - actual_user = rt_result["core"]["user_results"]["result"] - actual_user_legacy = actual_user.get("legacy", {}) + if is_retweet: + retweet_result = _deep_get(legacy, "retweeted_status_result", "result") or {} + if retweet_result.get("__typename") == "TweetWithVisibilityResults" and retweet_result.get("tweet"): + retweet_result = retweet_result["tweet"] + rt_legacy = retweet_result.get("legacy") + rt_core = retweet_result.get("core") + if isinstance(rt_legacy, dict) and isinstance(rt_core, dict): + actual_data = retweet_result + actual_legacy = rt_legacy + actual_user = _deep_get(rt_core, "user_results", "result") or {} + actual_user_legacy = actual_user.get("legacy", {}) - # Parse media - media = [] # type: List[TweetMedia] - ext_media = actual_legacy.get("extended_entities", {}).get("media", []) - for m in ext_media: - m_type = m.get("type", "") - if m_type == "photo": - media.append(TweetMedia( + media = [] # type: List[TweetMedia] + for media_item in _deep_get(actual_legacy, "extended_entities", "media") or []: + media_type = media_item.get("type", "") + if media_type == "photo": + media.append( + TweetMedia( type="photo", - url=m.get("media_url_https", ""), - width=_deep_get(m, "original_info", "width"), - height=_deep_get(m, "original_info", "height"), - )) - elif m_type in ("video", "animated_gif"): - variants = m.get("video_info", {}).get("variants", []) - mp4_variants = [v for v in variants if v.get("content_type") == "video/mp4"] - mp4_variants.sort(key=lambda v: v.get("bitrate", 0), reverse=True) - video_url = mp4_variants[0]["url"] if mp4_variants else m.get("media_url_https", "") - media.append(TweetMedia( - type=m_type, - url=video_url, - width=_deep_get(m, "original_info", "width"), - height=_deep_get(m, "original_info", "height"), - )) + url=media_item.get("media_url_https", ""), + width=_deep_get(media_item, "original_info", "width"), + height=_deep_get(media_item, "original_info", "height"), + ) + ) + elif media_type in {"video", "animated_gif"}: + variants = media_item.get("video_info", {}).get("variants", []) + mp4_variants = [item for item in variants if item.get("content_type") == "video/mp4"] + mp4_variants.sort(key=lambda item: item.get("bitrate", 0), reverse=True) + media.append( + TweetMedia( + type=media_type, + url=mp4_variants[0]["url"] if mp4_variants else media_item.get("media_url_https", ""), + width=_deep_get(media_item, "original_info", "width"), + height=_deep_get(media_item, "original_info", "height"), + ) + ) - # Parse URLs - urls = [u.get("expanded_url", "") for u in actual_legacy.get("entities", {}).get("urls", [])] + urls = [item.get("expanded_url", "") for item in _deep_get(actual_legacy, "entities", "urls") or []] + quoted = _deep_get(actual_data, "quoted_status_result", "result") + quoted_tweet = self._parse_tweet_result(quoted, depth=depth + 1) if isinstance(quoted, dict) else None - # Parse quoted tweet - quoted_tweet = None # type: Optional[Tweet] - quoted_result = actual_data.get("quoted_status_result", {}).get("result") - if quoted_result: - quoted_tweet = self._parse_tweet_result(quoted_result) + actual_user_core = actual_user.get("core", {}) + user_name = actual_user_core.get("name") or actual_user_legacy.get("name") or actual_user.get("name", "Unknown") + user_screen_name = ( + actual_user_core.get("screen_name") + or actual_user_legacy.get("screen_name") + or actual_user.get("screen_name", "unknown") + ) + user_profile_image = actual_user.get("avatar", {}).get("image_url") or actual_user_legacy.get("profile_image_url_https", "") + user_verified = bool(actual_user.get("is_blue_verified") or actual_user_legacy.get("verified", False)) + retweeted_by = None # type: Optional[str] + if is_retweet: + retweeted_by = user_core.get("screen_name") or user_legacy.get("screen_name", "unknown") - # Extract user info — try user.core (new API), then user.legacy (old API) - au = actual_user - aul = actual_user_legacy - auc = au.get("core", {}) - user_name = auc.get("name") or aul.get("name") or au.get("name", "Unknown") - user_screen_name = auc.get("screen_name") or aul.get("screen_name") or au.get("screen_name", "unknown") - user_profile_image = au.get("avatar", {}).get("image_url") or aul.get("profile_image_url_https", "") - user_verified = au.get("is_blue_verified") or aul.get("verified", False) - - # Retweeted by info - rt_screen_name = None # type: Optional[str] - if is_retweet: - rt_screen_name = user_core.get("screen_name") or user_legacy.get("screen_name", "unknown") - - return Tweet( - id=actual_data.get("rest_id", ""), - text=actual_legacy.get("full_text", ""), - author=Author( - id=au.get("rest_id", ""), - name=user_name, - screen_name=user_screen_name, - profile_image_url=user_profile_image, - verified=bool(user_verified), - ), - metrics=Metrics( - likes=actual_legacy.get("favorite_count", 0), - retweets=actual_legacy.get("retweet_count", 0), - replies=actual_legacy.get("reply_count", 0), - quotes=actual_legacy.get("quote_count", 0), - views=int(actual_data.get("views", {}).get("count", "0") or "0"), - bookmarks=actual_legacy.get("bookmark_count", 0), - ), - created_at=actual_legacy.get("created_at", ""), - media=media, - urls=urls, - is_retweet=is_retweet, - retweeted_by=rt_screen_name, - quoted_tweet=quoted_tweet, - lang=actual_legacy.get("lang", ""), - ) - except Exception as e: - logger.warning("Failed to parse tweet: %s", e) - return None + return Tweet( + id=actual_data.get("rest_id", ""), + text=actual_legacy.get("full_text", ""), + author=Author( + id=actual_user.get("rest_id", ""), + name=user_name, + screen_name=user_screen_name, + profile_image_url=user_profile_image, + verified=user_verified, + ), + metrics=Metrics( + likes=_to_int(actual_legacy.get("favorite_count"), 0), + retweets=_to_int(actual_legacy.get("retweet_count"), 0), + replies=_to_int(actual_legacy.get("reply_count"), 0), + quotes=_to_int(actual_legacy.get("quote_count"), 0), + views=_to_int(_deep_get(actual_data, "views", "count"), 0), + bookmarks=_to_int(actual_legacy.get("bookmark_count"), 0), + ), + created_at=actual_legacy.get("created_at", ""), + media=media, + urls=urls, + is_retweet=is_retweet, + retweeted_by=retweeted_by, + quoted_tweet=quoted_tweet, + lang=actual_legacy.get("lang", ""), + ) -def _deep_get(d, *keys): +def _deep_get(data, *keys): # type: (Any, *str) -> Any - """Safely get a nested value from a dict.""" + """Safely get nested dict values.""" + current = data for key in keys: - if isinstance(d, dict): - d = d.get(key) - else: + if not isinstance(current, dict): return None - return d + current = current.get(key) + return current + + +def _extract_cursor(content): + # type: (Dict[str, Any]) -> Optional[str] + """Extract pagination cursor from timeline content.""" + if content.get("cursorType") == "Bottom": + return content.get("value") + if content.get("entryType") == "TimelineTimelineCursor": + return content.get("value") + return None + + +def _to_int(value, default): + # type: (Any, int) -> int + """Best-effort integer conversion.""" + try: + text = str(value).replace(",", "").strip() + if not text: + return default + return int(float(text)) + except (TypeError, ValueError): + return default diff --git a/twitter_cli/config.py b/twitter_cli/config.py index c24261f..819a95b 100644 --- a/twitter_cli/config.py +++ b/twitter_cli/config.py @@ -1,15 +1,16 @@ -"""Configuration loader — reads config.yaml and merges with defaults. - -Uses a simple built-in YAML parser to avoid adding PyYAML as a dependency. -""" +"""Configuration loader with YAML parsing and normalization.""" from __future__ import annotations -import re +import copy +import logging from pathlib import Path -from typing import Any, Dict, List, Union -# Default configuration +import yaml + +logger = logging.getLogger(__name__) + + DEFAULT_CONFIG = { "fetch": { "count": 50, @@ -31,131 +32,118 @@ DEFAULT_CONFIG = { } # type: Dict[str, Any] -def _parse_value(s): - # type: (str) -> Union[str, int, float, bool] - """Parse a scalar YAML value.""" - if s == "true": - return True - if s == "false": - return False - # Remove surrounding quotes - if (s.startswith('"') and s.endswith('"')) or (s.startswith("'") and s.endswith("'")): - return s[1:-1] - # Try number +def load_config(config_path=None): + # type: (Optional[str]) -> Dict[str, Any] + """Load and normalize config from YAML, merged with defaults.""" + config = copy.deepcopy(DEFAULT_CONFIG) + path = _resolve_config_path(config_path) + if not path: + return config + try: - if "." in s: - return float(s) - return int(s) - except ValueError: - return s + raw = path.read_text(encoding="utf-8") + except OSError as exc: + logger.warning("Failed to read config file %s: %s", path, exc) + return config + + try: + parsed = yaml.safe_load(raw) or {} + except yaml.YAMLError as exc: + logger.warning("Failed to parse YAML config %s: %s", path, exc) + return config + + if not isinstance(parsed, dict): + logger.warning("Config root must be a mapping, got %s", type(parsed).__name__) + return config + + merged = _deep_merge(config, parsed) + return _normalize_config(merged) -def _parse_yaml(text): - # type: (str) -> Dict[str, Any] - """Minimal YAML parser for our flat config structure. +def _resolve_config_path(config_path): + # type: (Optional[str]) -> Optional[Path] + """Find config path from explicit argument or default locations.""" + if config_path: + path = Path(config_path) + return path if path.exists() else None - Supports: scalars, inline arrays [...], indented "- item" arrays, - nested objects via indentation. - """ - result = {} # type: Dict[str, Any] - lines = text.split("\n") - stack = [{"indent": -1, "obj": result}] # type: List[Dict[str, Any]] - - for line in lines: - # Strip comments and trailing whitespace - trimmed = re.sub(r"#.*$", "", line).rstrip() - if not trimmed or not trimmed.strip(): - continue - - indent = len(line) - len(line.lstrip()) - content = trimmed.strip() - - # Handle "- item" array entries - if content.startswith("- "): - parent = stack[-1]["obj"] - keys = list(parent.keys()) - if keys: - last_key = keys[-1] - if not isinstance(parent[last_key], list): - parent[last_key] = [] - parent[last_key].append(_parse_value(content[2:].strip())) - continue - - colon_idx = content.find(":") - if colon_idx == -1: - continue - - key = content[:colon_idx].strip() - raw_value = content[colon_idx + 1:].strip() - - # Pop stack to find parent at correct indentation - while len(stack) > 1 and stack[-1]["indent"] >= indent: - stack.pop() - parent = stack[-1]["obj"] - - if raw_value == "" or raw_value == "|": - # Nested object - child = {} # type: Dict[str, Any] - parent[key] = child - stack.append({"indent": indent, "obj": child}) - elif raw_value.startswith("[") and raw_value.endswith("]"): - # Inline array - inner = raw_value[1:-1].strip() - if inner == "": - parent[key] = [] - else: - parent[key] = [_parse_value(s.strip()) for s in inner.split(",")] - else: - parent[key] = _parse_value(raw_value) - - return result + candidates = [ + Path.cwd() / "config.yaml", + Path(__file__).parent.parent / "config.yaml", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return None def _deep_merge(target, source): - # type: (Dict[str, Any], Dict[str, Any]) -> Dict[str, Any] + # type: (Dict[str, Any], Mapping[str, Any]) -> Dict[str, Any] """Deep merge source into target (source values override target).""" - result = dict(target) - for key in source: - if ( - isinstance(source[key], dict) - and isinstance(result.get(key), dict) - ): - result[key] = _deep_merge(result[key], source[key]) + result = copy.deepcopy(target) + for key, value in source.items(): + if isinstance(value, dict) and isinstance(result.get(key), dict): + result[key] = _deep_merge(result[key], value) else: - result[key] = source[key] + result[key] = copy.deepcopy(value) return result -def load_config(config_path=None): - # type: (str) -> Dict[str, Any] - """Load config from config.yaml, merged with defaults.""" - if config_path is None: - # Look in current directory first, then script directory - candidates = [ - Path.cwd() / "config.yaml", - Path(__file__).parent.parent / "config.yaml", - ] - for p in candidates: - if p.exists(): - config_path = str(p) - break +def _normalize_config(config): + # type: (Dict[str, Any]) -> Dict[str, Any] + """Normalize shape and value types.""" + normalized = copy.deepcopy(DEFAULT_CONFIG) + merged = _deep_merge(normalized, config) - if config_path and Path(config_path).exists(): - try: - raw = Path(config_path).read_text(encoding="utf-8") - parsed = _parse_yaml(raw) - config = _deep_merge(DEFAULT_CONFIG, parsed) - except Exception: - config = dict(DEFAULT_CONFIG) - else: - config = dict(DEFAULT_CONFIG) + fetch = merged.get("fetch") + if not isinstance(fetch, dict): + fetch = {} + fetch_count = _as_int(fetch.get("count"), DEFAULT_CONFIG["fetch"]["count"]) + fetch["count"] = max(fetch_count, 1) + merged["fetch"] = fetch - # Ensure nested dicts exist - config.setdefault("fetch", DEFAULT_CONFIG["fetch"]) - config.setdefault("filter", DEFAULT_CONFIG["filter"]) + filter_config = merged.get("filter") + if not isinstance(filter_config, dict): + filter_config = {} + mode = str(filter_config.get("mode", "topN")) + if mode not in {"topN", "score", "all"}: + mode = "topN" + filter_config["mode"] = mode + filter_config["topN"] = max(_as_int(filter_config.get("topN"), 20), 1) + filter_config["minScore"] = _as_float(filter_config.get("minScore"), 50.0) + filter_config["excludeRetweets"] = bool(filter_config.get("excludeRetweets", False)) - # Deep-copy filter weights if needed - if "filter" in config and "weights" not in config["filter"]: - config["filter"]["weights"] = dict(DEFAULT_CONFIG["filter"]["weights"]) + langs = filter_config.get("lang", []) + if not isinstance(langs, list): + langs = [] + filter_config["lang"] = [str(lang) for lang in langs if str(lang)] - return config + weights = filter_config.get("weights", {}) + if not isinstance(weights, dict): + weights = {} + normalized_weights = {} + default_weights = DEFAULT_CONFIG["filter"]["weights"] + for key, default_value in default_weights.items(): + normalized_weights[key] = _as_float(weights.get(key), float(default_value)) + filter_config["weights"] = normalized_weights + merged["filter"] = filter_config + + return merged + + +def _as_int(value, default): + # type: (Any, int) -> int + """Best-effort int conversion.""" + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _as_float(value, default): + # type: (Any, float) -> float + """Best-effort float conversion.""" + try: + return float(value) + except (TypeError, ValueError): + return default diff --git a/twitter_cli/filter.py b/twitter_cli/filter.py index 8f42fa5..94e74f7 100644 --- a/twitter_cli/filter.py +++ b/twitter_cli/filter.py @@ -6,14 +6,14 @@ configurable rules (topN, min score, language, etc.). from __future__ import annotations +from dataclasses import replace import math -from typing import Dict, List +from typing import Mapping -from .models import Tweet # Type alias for filter weights dict -FilterWeights = Dict[str, float] +FilterWeights = Mapping[str, float] DEFAULT_WEIGHTS = { "likes": 1.0, @@ -25,7 +25,7 @@ DEFAULT_WEIGHTS = { def score_tweet(tweet, weights=None): - # type: (Tweet, FilterWeights) -> float + # type: (Tweet, Optional[FilterWeights]) -> float """Calculate engagement score for a single tweet. Formula: @@ -35,20 +35,19 @@ def score_tweet(tweet, weights=None): + w_bookmarks × bookmarks + w_views_log × log10(views) """ - if weights is None: - weights = DEFAULT_WEIGHTS + weight_map = _build_weights(weights or {}) m = tweet.metrics return ( - weights.get("likes", 1.0) * m.likes - + weights.get("retweets", 3.0) * m.retweets - + weights.get("replies", 2.0) * m.replies - + weights.get("bookmarks", 5.0) * m.bookmarks - + weights.get("views_log", 0.5) * math.log10(max(m.views, 1)) + weight_map["likes"] * m.likes + + weight_map["retweets"] * m.retweets + + weight_map["replies"] * m.replies + + weight_map["bookmarks"] * m.bookmarks + + weight_map["views_log"] * math.log10(max(m.views, 1)) ) def filter_tweets(tweets, config): - # type: (List[Tweet], dict) -> List[Tweet] + # type: (Sequence[Tweet], Mapping[str, Any]) -> List[Tweet] """Filter and rank tweets according to config. Config keys: @@ -64,27 +63,53 @@ def filter_tweets(tweets, config): # 1. Language filter lang_filter = config.get("lang", []) if lang_filter: - filtered = [t for t in filtered if t.lang in lang_filter] + lang_set = {str(lang) for lang in lang_filter if str(lang)} + filtered = [tweet for tweet in filtered if tweet.lang in lang_set] # 2. Exclude retweets if config.get("excludeRetweets", False): - filtered = [t for t in filtered if not t.is_retweet] + filtered = [tweet for tweet in filtered if not tweet.is_retweet] # 3. Score all tweets - weights = config.get("weights", DEFAULT_WEIGHTS) - for t in filtered: - t.score = round(score_tweet(t, weights), 1) + weights = _build_weights(config.get("weights", {})) + scored = [replace(tweet, score=round(score_tweet(tweet, weights), 1)) for tweet in filtered] # 4. Sort by score (descending) - filtered.sort(key=lambda t: t.score, reverse=True) + scored.sort(key=lambda tweet: tweet.score, reverse=True) # 5. Apply filter mode - mode = config.get("mode", "topN") + mode = str(config.get("mode", "topN")) if mode == "topN": - top_n = config.get("topN", 20) - return filtered[:top_n] - elif mode == "score": - min_score = config.get("minScore", 50) - return [t for t in filtered if t.score >= min_score] - else: - return filtered + top_n = max(_as_int(config.get("topN"), 20), 1) + return scored[:top_n] + if mode == "score": + min_score = _as_float(config.get("minScore"), 50.0) + return [tweet for tweet in scored if tweet.score >= min_score] + return scored + + +def _build_weights(raw_weights): + # type: (Mapping[str, Any]) -> Dict[str, float] + """Merge custom weights with defaults and coerce to float.""" + merged = {} + for key, default_value in DEFAULT_WEIGHTS.items(): + merged[key] = _as_float(raw_weights.get(key), default_value) + return merged + + +def _as_int(value, default): + # type: (Any, int) -> int + """Best-effort int conversion.""" + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _as_float(value, default): + # type: (Any, float) -> float + """Best-effort float conversion.""" + try: + return float(value) + except (TypeError, ValueError): + return default diff --git a/twitter_cli/formatter.py b/twitter_cli/formatter.py index 62ec741..d40fe13 100644 --- a/twitter_cli/formatter.py +++ b/twitter_cli/formatter.py @@ -2,15 +2,12 @@ from __future__ import annotations -import json -from typing import List, Optional from rich.console import Console from rich.panel import Panel from rich.table import Table -from rich.text import Text -from .models import Tweet, UserProfile +from .serialization import tweets_to_json as _tweets_to_json def format_number(n): @@ -168,46 +165,7 @@ def print_filter_stats(original_count, filtered, console=None): def tweets_to_json(tweets): # type: (List[Tweet]) -> str """Export tweets as JSON string.""" - result = [] - for t in tweets: - d = { - "id": t.id, - "text": t.text, - "author": { - "id": t.author.id, - "name": t.author.name, - "screenName": t.author.screen_name, - "profileImageUrl": t.author.profile_image_url, - "verified": t.author.verified, - }, - "metrics": { - "likes": t.metrics.likes, - "retweets": t.metrics.retweets, - "replies": t.metrics.replies, - "quotes": t.metrics.quotes, - "views": t.metrics.views, - "bookmarks": t.metrics.bookmarks, - }, - "createdAt": t.created_at, - "media": [ - {"type": m.type, "url": m.url, "width": m.width, "height": m.height} - for m in t.media - ], - "urls": t.urls, - "isRetweet": t.is_retweet, - "retweetedBy": t.retweeted_by, - "lang": t.lang, - "score": t.score, - } - if t.quoted_tweet: - qt = t.quoted_tweet - d["quotedTweet"] = { - "id": qt.id, - "text": qt.text, - "author": {"screenName": qt.author.screen_name, "name": qt.author.name}, - } - result.append(d) - return json.dumps(result, ensure_ascii=False, indent=2) + return _tweets_to_json(tweets) def print_user_profile(user, console=None): diff --git a/twitter_cli/serialization.py b/twitter_cli/serialization.py new file mode 100644 index 0000000..3909954 --- /dev/null +++ b/twitter_cli/serialization.py @@ -0,0 +1,147 @@ +"""Serialization helpers for Tweet models.""" + +from __future__ import annotations + +import json +from typing import Any, Dict, Iterable, List, Optional + +from .models import Author, Metrics, Tweet, TweetMedia + + +def tweet_to_dict(tweet: Tweet) -> Dict[str, Any]: + """Convert a Tweet dataclass into a JSON-safe dict.""" + data = { + "id": tweet.id, + "text": tweet.text, + "author": { + "id": tweet.author.id, + "name": tweet.author.name, + "screenName": tweet.author.screen_name, + "profileImageUrl": tweet.author.profile_image_url, + "verified": tweet.author.verified, + }, + "metrics": { + "likes": tweet.metrics.likes, + "retweets": tweet.metrics.retweets, + "replies": tweet.metrics.replies, + "quotes": tweet.metrics.quotes, + "views": tweet.metrics.views, + "bookmarks": tweet.metrics.bookmarks, + }, + "createdAt": tweet.created_at, + "media": [ + { + "type": media.type, + "url": media.url, + "width": media.width, + "height": media.height, + } + for media in tweet.media + ], + "urls": list(tweet.urls), + "isRetweet": tweet.is_retweet, + "retweetedBy": tweet.retweeted_by, + "lang": tweet.lang, + "score": tweet.score, + } + if tweet.quoted_tweet: + data["quotedTweet"] = { + "id": tweet.quoted_tweet.id, + "text": tweet.quoted_tweet.text, + "author": { + "screenName": tweet.quoted_tweet.author.screen_name, + "name": tweet.quoted_tweet.author.name, + }, + } + return data + + +def tweet_from_dict(data: Dict[str, Any]) -> Tweet: + """Convert a dict into a Tweet dataclass.""" + author_data = data.get("author") or {} + metrics_data = data.get("metrics") or {} + media_data = data.get("media") or [] + quoted_data = data.get("quotedTweet") + + quoted_tweet = None # type: Optional[Tweet] + if isinstance(quoted_data, dict): + quoted_author = quoted_data.get("author") or {} + quoted_tweet = Tweet( + id=str(quoted_data.get("id") or ""), + text=str(quoted_data.get("text") or ""), + author=Author( + id="", + name=str(quoted_author.get("name") or ""), + screen_name=str(quoted_author.get("screenName") or ""), + ), + metrics=Metrics(), + created_at="", + ) + + return Tweet( + id=str(data.get("id") or ""), + text=str(data.get("text") or ""), + author=Author( + id=str(author_data.get("id") or ""), + name=str(author_data.get("name") or ""), + screen_name=str(author_data.get("screenName") or ""), + profile_image_url=str(author_data.get("profileImageUrl") or ""), + verified=bool(author_data.get("verified", False)), + ), + metrics=Metrics( + likes=int(metrics_data.get("likes") or 0), + retweets=int(metrics_data.get("retweets") or 0), + replies=int(metrics_data.get("replies") or 0), + quotes=int(metrics_data.get("quotes") or 0), + views=int(metrics_data.get("views") or 0), + bookmarks=int(metrics_data.get("bookmarks") or 0), + ), + created_at=str(data.get("createdAt") or ""), + media=[ + TweetMedia( + type=str(item.get("type") or ""), + url=str(item.get("url") or ""), + width=_optional_int(item.get("width")), + height=_optional_int(item.get("height")), + ) + for item in media_data + if isinstance(item, dict) + ], + urls=[str(url) for url in (data.get("urls") or [])], + is_retweet=bool(data.get("isRetweet", False)), + lang=str(data.get("lang") or ""), + retweeted_by=_optional_str(data.get("retweetedBy")), + quoted_tweet=quoted_tweet, + score=float(data.get("score") or 0.0), + ) + + +def tweets_from_json(raw: str) -> List[Tweet]: + """Parse a JSON string into Tweet objects.""" + payload = json.loads(raw) + if not isinstance(payload, list): + raise ValueError("Tweet JSON payload must be a list") + return [tweet_from_dict(item) for item in payload if isinstance(item, dict)] + + +def tweets_to_json(tweets: Iterable[Tweet]) -> str: + """Serialize Tweet objects to pretty JSON.""" + return json.dumps([tweet_to_dict(tweet) for tweet in tweets], ensure_ascii=False, indent=2) + + +def _optional_int(value: Any) -> Optional[int]: + """Parse an optional integer value.""" + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _optional_str(value: Any) -> Optional[str]: + """Parse an optional string value.""" + if value is None: + return None + text = str(value) + return text if text else None diff --git a/uv.lock b/uv.lock index c25540b..3d71ac8 100644 --- a/uv.lock +++ b/uv.lock @@ -63,6 +63,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.9.*'", + "python_full_version < '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "jeepney" version = "0.9.0" @@ -226,6 +264,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955, upload-time = "2024-04-20T21:34:42.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556, upload-time = "2024-04-20T21:34:40.434Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pycryptodomex" version = "3.23.0" @@ -275,6 +347,68 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pytest" +version = "8.3.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.9' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.9'" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "packaging", marker = "python_full_version < '3.9'" }, + { name = "pluggy", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "tomli", marker = "python_full_version < '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.9.*'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version == '3.9.*' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version == '3.9.*'" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "packaging", marker = "python_full_version == '3.9.*'" }, + { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "pygments", marker = "python_full_version == '3.9.*'" }, + { name = "tomli", marker = "python_full_version == '3.9.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, + { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, + { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pygments", marker = "python_full_version >= '3.10'" }, + { name = "tomli", marker = "python_full_version == '3.10.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + [[package]] name = "pywin32" version = "311" @@ -302,6 +436,86 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/22/e0e8d802f124772cec9c75430b01a212f86f9de7546bda715e54140d5aeb/pywin32-311-cp39-cp39-win_arm64.whl", hash = "sha256:62ea666235135fee79bb154e695f3ff67370afefd71bd7fea7512fc70ef31e3d", size = 8778162, upload-time = "2025-07-14T20:13:03.544Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/a2/09f67a3589cb4320fb5ce90d3fd4c9752636b8b6ad8f34b54d76c5a54693/PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f", size = 186824, upload-time = "2025-09-29T20:27:35.918Z" }, + { url = "https://files.pythonhosted.org/packages/02/72/d972384252432d57f248767556ac083793292a4adf4e2d85dfe785ec2659/PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4", size = 795069, upload-time = "2025-09-29T20:27:38.15Z" }, + { url = "https://files.pythonhosted.org/packages/a7/3b/6c58ac0fa7c4e1b35e48024eb03d00817438310447f93ef4431673c24138/PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3", size = 862585, upload-time = "2025-09-29T20:27:39.715Z" }, + { url = "https://files.pythonhosted.org/packages/25/a2/b725b61ac76a75583ae7104b3209f75ea44b13cfd026aa535ece22b7f22e/PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6", size = 806018, upload-time = "2025-09-29T20:27:41.444Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b0/b2227677b2d1036d84f5ee95eb948e7af53d59fe3e4328784e4d290607e0/PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369", size = 802822, upload-time = "2025-09-29T20:27:42.885Z" }, + { url = "https://files.pythonhosted.org/packages/99/a5/718a8ea22521e06ef19f91945766a892c5ceb1855df6adbde67d997ea7ed/PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295", size = 143744, upload-time = "2025-09-29T20:27:44.487Z" }, + { url = "https://files.pythonhosted.org/packages/76/b2/2b69cee94c9eb215216fc05778675c393e3aa541131dc910df8e52c83776/PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b", size = 160082, upload-time = "2025-09-29T20:27:46.049Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/67fc8e68a75f738c9200422bf65693fb79a4cd0dc5b23310e5202e978090/pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", size = 184450, upload-time = "2025-09-25T21:33:00.618Z" }, + { url = "https://files.pythonhosted.org/packages/ae/92/861f152ce87c452b11b9d0977952259aa7df792d71c1053365cc7b09cc08/pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", size = 174319, upload-time = "2025-09-25T21:33:02.086Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cd/f0cfc8c74f8a030017a2b9c771b7f47e5dd702c3e28e5b2071374bda2948/pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", size = 737631, upload-time = "2025-09-25T21:33:03.25Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b2/18f2bd28cd2055a79a46c9b0895c0b3d987ce40ee471cecf58a1a0199805/pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", size = 836795, upload-time = "2025-09-25T21:33:05.014Z" }, + { url = "https://files.pythonhosted.org/packages/73/b9/793686b2d54b531203c160ef12bec60228a0109c79bae6c1277961026770/pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", size = 750767, upload-time = "2025-09-25T21:33:06.398Z" }, + { url = "https://files.pythonhosted.org/packages/a9/86/a137b39a611def2ed78b0e66ce2fe13ee701a07c07aebe55c340ed2a050e/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", size = 727982, upload-time = "2025-09-25T21:33:08.708Z" }, + { url = "https://files.pythonhosted.org/packages/dd/62/71c27c94f457cf4418ef8ccc71735324c549f7e3ea9d34aba50874563561/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", size = 755677, upload-time = "2025-09-25T21:33:09.876Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/6f5e0d58bd924fb0d06c3a6bad00effbdae2de5adb5cda5648006ffbd8d3/pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", size = 142592, upload-time = "2025-09-25T21:33:10.983Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777, upload-time = "2025-09-25T21:33:15.55Z" }, +] + [[package]] name = "rich" version = "14.3.3" @@ -316,6 +530,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, ] +[[package]] +name = "ruff" +version = "0.15.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/31/d6e536cdebb6568ae75a7f00e4b4819ae0ad2640c3604c305a0428680b0c/ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1", size = 4569550, upload-time = "2026-02-26T20:04:14.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/82/c11a03cfec3a4d26a0ea1e571f0f44be5993b923f905eeddfc397c13d360/ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0", size = 10453333, upload-time = "2026-02-26T20:04:20.093Z" }, + { url = "https://files.pythonhosted.org/packages/ce/5d/6a1f271f6e31dffb31855996493641edc3eef8077b883eaf007a2f1c2976/ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992", size = 10853356, upload-time = "2026-02-26T20:04:05.808Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d8/0fab9f8842b83b1a9c2bf81b85063f65e93fb512e60effa95b0be49bfc54/ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba", size = 10187434, upload-time = "2026-02-26T20:03:54.656Z" }, + { url = "https://files.pythonhosted.org/packages/85/cc/cc220fd9394eff5db8d94dec199eec56dd6c9f3651d8869d024867a91030/ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75", size = 10535456, upload-time = "2026-02-26T20:03:52.738Z" }, + { url = "https://files.pythonhosted.org/packages/fa/0f/bced38fa5cf24373ec767713c8e4cadc90247f3863605fb030e597878661/ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac", size = 10287772, upload-time = "2026-02-26T20:04:08.138Z" }, + { url = "https://files.pythonhosted.org/packages/2b/90/58a1802d84fed15f8f281925b21ab3cecd813bde52a8ca033a4de8ab0e7a/ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a", size = 11049051, upload-time = "2026-02-26T20:04:03.53Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ac/b7ad36703c35f3866584564dc15f12f91cb1a26a897dc2fd13d7cb3ae1af/ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85", size = 11890494, upload-time = "2026-02-26T20:04:10.497Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/3eb2f47a39a8b0da99faf9c54d3eb24720add1e886a5309d4d1be73a6380/ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db", size = 11326221, upload-time = "2026-02-26T20:04:12.84Z" }, + { url = "https://files.pythonhosted.org/packages/ff/90/bf134f4c1e5243e62690e09d63c55df948a74084c8ac3e48a88468314da6/ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec", size = 11168459, upload-time = "2026-02-26T20:04:00.969Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e5/a64d27688789b06b5d55162aafc32059bb8c989c61a5139a36e1368285eb/ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f", size = 11104366, upload-time = "2026-02-26T20:03:48.099Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f6/32d1dcb66a2559763fc3027bdd65836cad9eb09d90f2ed6a63d8e9252b02/ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338", size = 10510887, upload-time = "2026-02-26T20:03:45.771Z" }, + { url = "https://files.pythonhosted.org/packages/ff/92/22d1ced50971c5b6433aed166fcef8c9343f567a94cf2b9d9089f6aa80fe/ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc", size = 10285939, upload-time = "2026-02-26T20:04:22.42Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f4/7c20aec3143837641a02509a4668fb146a642fd1211846634edc17eb5563/ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68", size = 10765471, upload-time = "2026-02-26T20:03:58.924Z" }, + { url = "https://files.pythonhosted.org/packages/d0/09/6d2f7586f09a16120aebdff8f64d962d7c4348313c77ebb29c566cefc357/ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3", size = 11263382, upload-time = "2026-02-26T20:04:24.424Z" }, + { url = "https://files.pythonhosted.org/packages/1b/fa/2ef715a1cd329ef47c1a050e10dee91a9054b7ce2fcfdd6a06d139afb7ec/ruff-0.15.4-py3-none-win32.whl", hash = "sha256:65594a2d557d4ee9f02834fcdf0a28daa8b3b9f6cb2cb93846025a36db47ef22", size = 10506664, upload-time = "2026-02-26T20:03:50.56Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a8/c688ef7e29983976820d18710f955751d9f4d4eb69df658af3d006e2ba3e/ruff-0.15.4-py3-none-win_amd64.whl", hash = "sha256:04196ad44f0df220c2ece5b0e959c2f37c777375ec744397d21d15b50a75264f", size = 11651048, upload-time = "2026-02-26T20:04:17.191Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0a/9e1be9035b37448ce2e68c978f0591da94389ade5a5abafa4cf99985d1b2/ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453", size = 10966776, upload-time = "2026-02-26T20:03:56.908Z" }, +] + [[package]] name = "shadowcopy" version = "0.0.4" @@ -328,6 +567,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ad/32/fdea8e7f7b2b8bae13ee6ab7df1a10d28a24dbeb03a62ff033563f95d77c/shadowcopy-0.0.4-py3-none-any.whl", hash = "sha256:fc51e59a639dc6a5a3a7a9b4e3ecadc71989e339f2d995d90aaa491acd4ba4eb", size = 4212, upload-time = "2023-07-08T00:01:34.042Z" }, ] +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + [[package]] name = "twitter-cli" version = "0.1.0" @@ -336,14 +629,52 @@ dependencies = [ { name = "browser-cookie3" }, { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "click", version = "8.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pyyaml" }, { name = "rich" }, ] +[package.optional-dependencies] +dev = [ + { name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "ruff" }, +] + [package.metadata] requires-dist = [ { name = "browser-cookie3", specifier = ">=0.19" }, { name = "click", specifier = ">=8.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, + { name = "pyyaml", specifier = ">=6.0" }, { name = "rich", specifier = ">=13.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8" }, +] +provides-extras = ["dev"] + +[[package]] +name = "typing-extensions" +version = "4.13.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] [[package]]