feat: add integration smoke tests

CLI-level smoke tests using --yaml output against real Twitter API.
Default skipped via @pytest.mark.smoke marker + pyproject.toml addopts.
Run locally with: uv run pytest -m smoke -v
This commit is contained in:
jackwener
2026-03-10 22:26:46 +08:00
parent fa6255f2ee
commit 9cf74abd56
13 changed files with 562 additions and 193 deletions

View File

@@ -9,6 +9,9 @@ on:
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.8", "3.10", "3.12"]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -19,7 +22,7 @@ jobs:
- name: Setup Python - name: Setup Python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: "3.12" python-version: ${{ matrix.python-version }}
- name: Install dependencies - name: Install dependencies
run: uv sync --extra dev run: uv sync --extra dev

View File

@@ -38,6 +38,7 @@ A terminal-first CLI for Twitter/X: read timelines, bookmarks, and user profiles
- Like / Unlike: manage tweet likes - Like / Unlike: manage tweet likes
- Retweet / Unretweet: manage retweets - Retweet / Unretweet: manage retweets
- Bookmark: bookmark/unbookmark (`favorite/unfavorite` kept as compatibility aliases) - Bookmark: bookmark/unbookmark (`favorite/unfavorite` kept as compatibility aliases)
- Write commands also support explicit `--json` / `--yaml` output now
**Auth & Anti-Detection:** **Auth & Anti-Detection:**
- Cookie auth: use browser cookies or environment variables - Cookie auth: use browser cookies or environment variables
@@ -117,13 +118,16 @@ twitter following elonmusk --max 50
# Write operations # Write operations
twitter post "Hello from twitter-cli!" twitter post "Hello from twitter-cli!"
twitter post "reply text" --reply-to 1234567890 twitter post "reply text" --reply-to 1234567890
twitter post "Hello from twitter-cli!" --json
twitter delete 1234567890 twitter delete 1234567890
twitter like 1234567890 twitter like 1234567890
twitter like 1234567890 --yaml
twitter unlike 1234567890 twitter unlike 1234567890
twitter retweet 1234567890 twitter retweet 1234567890
twitter unretweet 1234567890 twitter unretweet 1234567890
twitter bookmark 1234567890 twitter bookmark 1234567890
twitter unbookmark 1234567890 twitter unbookmark 1234567890
twitter follow elonmusk --json
``` ```
### Authentication ### Authentication
@@ -229,6 +233,8 @@ Mode behavior:
- `Invalid tweet JSON file` - `Invalid tweet JSON file`
- Regenerate input using `twitter feed --json > tweets.json`. - Regenerate input using `twitter feed --json > tweets.json`.
Structured error codes commonly include `not_authenticated`, `not_found`, `invalid_input`, `rate_limited`, and `api_error`.
### Development ### Development
```bash ```bash
@@ -240,7 +246,7 @@ uv run ruff check .
uv run pytest -q uv run pytest -q
``` ```
Current CI validates the project on Python 3.12. Current CI validates the project on Python 3.8, 3.10, and 3.12.
### Project Structure ### Project Structure
@@ -305,6 +311,7 @@ After installation, OpenClaw can call `twitter-cli` commands directly.
- 点赞 / 取消点赞 - 点赞 / 取消点赞
- 转推 / 取消转推 - 转推 / 取消转推
- 书签 / 取消书签bookmark/unbookmark保留 `favorite/unfavorite` 兼容别名) - 书签 / 取消书签bookmark/unbookmark保留 `favorite/unfavorite` 兼容别名)
- 写操作现在也显式支持 `--json` / `--yaml`
**认证与反风控:** **认证与反风控:**
- Cookie 认证:支持环境变量和浏览器自动提取 - Cookie 认证:支持环境变量和浏览器自动提取
@@ -357,13 +364,16 @@ twitter following elonmusk
# 写操作 # 写操作
twitter post "你好,世界!" twitter post "你好,世界!"
twitter post "回复内容" --reply-to 1234567890 twitter post "回复内容" --reply-to 1234567890
twitter post "你好,世界!" --json
twitter delete 1234567890 twitter delete 1234567890
twitter like 1234567890 twitter like 1234567890
twitter like 1234567890 --yaml
twitter unlike 1234567890 twitter unlike 1234567890
twitter retweet 1234567890 twitter retweet 1234567890
twitter unretweet 1234567890 twitter unretweet 1234567890
twitter bookmark 1234567890 twitter bookmark 1234567890
twitter unbookmark 1234567890 twitter unbookmark 1234567890
twitter follow elonmusk --json
``` ```
### 认证说明 ### 认证说明
@@ -415,6 +425,7 @@ score = likes_w * likes
- 如需查看浏览器提取细节,可加 `-v` 打开诊断日志。 - 如需查看浏览器提取细节,可加 `-v` 打开诊断日志。
- 报错 `Cookie expired or invalid`Cookie 过期,重新登录后重试。 - 报错 `Cookie expired or invalid`Cookie 过期,重新登录后重试。
- 报错 `Twitter API error 404`:通常是 queryId 轮换,重试即可。 - 报错 `Twitter API error 404`:通常是 queryId 轮换,重试即可。
- 结构化错误码通常会区分 `not_authenticated``not_found``invalid_input``rate_limited``api_error`
### 使用建议(防封号) ### 使用建议(防封号)

View File

@@ -27,3 +27,14 @@ error:
- tweet and user lists are returned under `data` - tweet and user lists are returned under `data`
- `status` returns `data.authenticated` plus `data.user` - `status` returns `data.authenticated` plus `data.user`
- `whoami` returns `data.user` - `whoami` returns `data.user`
- write commands also support explicit `--json` / `--yaml`
## Error Codes
Common structured error codes:
- `not_authenticated`
- `not_found`
- `invalid_input`
- `rate_limited`
- `api_error`

View File

@@ -33,6 +33,8 @@ dependencies = [
dev = [ dev = [
"pytest>=8.0", "pytest>=8.0",
"ruff>=0.8", "ruff>=0.8",
"mypy>=1.14,<1.15",
"types-PyYAML>=6.0.12",
] ]
[project.urls] [project.urls]
@@ -46,9 +48,20 @@ twitter = "twitter_cli.cli:cli"
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
python_files = ["test_*.py"] python_files = ["test_*.py"]
addopts = "-m 'not smoke'"
markers = [
"smoke: real-API integration tests (run with: pytest -m smoke)",
]
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100
[tool.mypy]
python_version = "3.8"
ignore_missing_imports = true
check_untyped_defs = true
warn_unused_ignores = true
no_implicit_optional = true
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["twitter_cli"] packages = ["twitter_cli"]

View File

@@ -68,7 +68,7 @@ def test_cli_user_error_yaml(monkeypatch) -> None:
assert result.exit_code == 1 assert result.exit_code == 1
payload = yaml.safe_load(result.output) payload = yaml.safe_load(result.output)
assert payload["ok"] is False assert payload["ok"] is False
assert payload["error"]["code"] == "api_error" assert payload["error"]["code"] == "not_found"
def test_cli_tweet_accepts_shared_url_with_query(monkeypatch) -> None: def test_cli_tweet_accepts_shared_url_with_query(monkeypatch) -> None:
@@ -198,6 +198,62 @@ def test_cli_quote_command(monkeypatch) -> None:
assert calls[0]["text"] == "Interesting!" assert calls[0]["text"] == "Interesting!"
def test_cli_post_json_output(monkeypatch) -> None:
class FakeClient:
def create_tweet(self, text: str, reply_to_id=None) -> str:
assert text == "hello"
assert reply_to_id is None
return "999"
monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None: FakeClient())
runner = CliRunner()
result = runner.invoke(cli, ["post", "hello", "--json"])
assert result.exit_code == 0
payload = yaml.safe_load(result.output)
assert payload["ok"] is True
assert payload["data"]["action"] == "post"
assert payload["data"]["id"] == "999"
def test_cli_like_yaml_output(monkeypatch) -> None:
class FakeClient:
def like_tweet(self, tweet_id: str) -> bool:
assert tweet_id == "123"
return True
monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None: FakeClient())
runner = CliRunner()
result = runner.invoke(cli, ["like", "123", "--yaml"])
assert result.exit_code == 0
payload = yaml.safe_load(result.output)
assert payload["ok"] is True
assert payload["data"]["action"] == "liking_tweet"
assert payload["data"]["id"] == "123"
def test_cli_follow_json_output(monkeypatch) -> None:
class FakeClient:
def resolve_user_id(self, identifier: str) -> str:
assert identifier == "alice"
return "42"
def follow_user(self, user_id: str) -> bool:
assert user_id == "42"
return True
monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None: FakeClient())
runner = CliRunner()
result = runner.invoke(cli, ["follow", "alice", "--json"])
assert result.exit_code == 0
payload = yaml.safe_load(result.output)
assert payload["ok"] is True
assert payload["data"]["action"] == "follow"
assert payload["data"]["userId"] == "42"
def test_cli_follow_command(monkeypatch) -> None: def test_cli_follow_command(monkeypatch) -> None:
actions = [] actions = []

85
tests/test_smoke.py Normal file
View File

@@ -0,0 +1,85 @@
"""Integration smoke tests for twitter-cli.
These tests invoke the real CLI commands with ``--yaml`` against the live
Twitter/X API using your local browser cookies. They are **skipped by
default** and only run when explicitly requested::
uv run pytest -m smoke -v
Only read-only operations are tested — no writes.
"""
from __future__ import annotations
import yaml
import pytest
from click.testing import CliRunner
from twitter_cli.cli import cli
smoke = pytest.mark.smoke
runner = CliRunner()
def _invoke(*args: str):
"""Run a CLI command with --yaml and return parsed payload."""
result = runner.invoke(cli, [*args, "--yaml"])
if result.output:
payload = yaml.safe_load(result.output)
else:
payload = None
return result, payload
# ── Auth ────────────────────────────────────────────────────────────────
@smoke
class TestAuth:
"""Verify authentication is working end-to-end."""
def test_status(self):
result, payload = _invoke("status")
assert result.exit_code == 0, f"status failed: {result.output}"
assert payload["ok"] is True
assert payload["data"]["authenticated"] is True
assert payload["data"]["user"]["username"]
def test_whoami(self):
result, payload = _invoke("whoami")
assert result.exit_code == 0, f"whoami failed: {result.output}"
assert payload["ok"] is True
assert payload["data"]["user"]["username"]
assert payload["data"]["user"]["id"]
# ── Read-only queries ───────────────────────────────────────────────────
@smoke
class TestReadOnly:
"""Read-only CLI smoke tests."""
def test_user(self):
result, payload = _invoke("user", "elonmusk")
assert result.exit_code == 0, f"user failed: {result.output}"
assert payload["ok"] is True
def test_search(self):
result, payload = _invoke("search", "python", "--max", "3")
assert result.exit_code == 0, f"search failed: {result.output}"
assert payload["ok"] is True
items = payload["data"]
assert isinstance(items, list)
assert len(items) >= 1
def test_user_posts(self):
result, payload = _invoke("user-posts", "elonmusk", "--max", "3")
assert result.exit_code == 0, f"user-posts failed: {result.output}"
assert payload["ok"] is True
def test_feed(self):
result, payload = _invoke("feed", "--max", "3")
assert result.exit_code == 0, f"feed failed: {result.output}"
assert payload["ok"] is True

View File

@@ -15,7 +15,7 @@ import logging
import os import os
import subprocess import subprocess
import sys import sys
from typing import Dict, Optional from typing import Any, Dict, Optional, Tuple
from .constants import BEARER_TOKEN, get_user_agent from .constants import BEARER_TOKEN, get_user_agent
@@ -25,8 +25,7 @@ logger = logging.getLogger(__name__)
_TWITTER_DOMAINS = {"x.com", "twitter.com", ".x.com", ".twitter.com"} _TWITTER_DOMAINS = {"x.com", "twitter.com", ".x.com", ".twitter.com"}
def _is_twitter_domain(domain): def _is_twitter_domain(domain: str) -> bool:
# type: (str) -> bool
return domain in _TWITTER_DOMAINS or domain.endswith(".x.com") or domain.endswith(".twitter.com") return domain in _TWITTER_DOMAINS or domain.endswith(".x.com") or domain.endswith(".twitter.com")
@@ -45,8 +44,7 @@ def load_from_env() -> Optional[Dict[str, str]]:
return None return None
def verify_cookies(auth_token, ct0, cookie_string=None): def verify_cookies(auth_token: str, ct0: str, cookie_string: Optional[str] = None) -> Dict[str, Any]:
# type: (str, str, Optional[str]) -> Dict[str, Any]
"""Verify cookies by calling a Twitter API endpoint. """Verify cookies by calling a Twitter API endpoint.
Uses curl_cffi for proper TLS fingerprint. Uses curl_cffi for proper TLS fingerprint.
@@ -112,11 +110,10 @@ def verify_cookies(auth_token, ct0, cookie_string=None):
return {} return {}
def _extract_cookies_from_jar(jar, source="unknown"): def _extract_cookies_from_jar(jar: Any, source: str = "unknown") -> Optional[Dict[str, str]]:
# type: (Any, str) -> Optional[Dict[str, str]]
"""Extract Twitter cookies from a cookie jar.""" """Extract Twitter cookies from a cookie jar."""
result = {} # type: Dict[str, str] result: Dict[str, str] = {}
all_cookies = {} # type: Dict[str, str] all_cookies: Dict[str, str] = {}
twitter_cookie_count = 0 twitter_cookie_count = 0
for cookie in jar: for cookie in jar:
domain = cookie.domain or "" domain = cookie.domain or ""
@@ -144,8 +141,7 @@ def _extract_cookies_from_jar(jar, source="unknown"):
return None return None
def _extract_in_process(): def _extract_in_process() -> Optional[Dict[str, str]]:
# type: () -> Optional[Dict[str, str]]
"""Extract cookies in the main process (required on macOS for Keychain access). """Extract cookies in the main process (required on macOS for Keychain access).
On macOS, Chrome encrypts cookies using a key stored in the system Keychain. On macOS, Chrome encrypts cookies using a key stored in the system Keychain.
@@ -184,8 +180,7 @@ def _extract_in_process():
return None return None
def _extract_via_subprocess(): def _extract_via_subprocess() -> Optional[Dict[str, str]]:
# type: () -> Optional[Dict[str, str]]
"""Extract cookies via subprocess (fallback if in-process fails, e.g. SQLite lock).""" """Extract cookies via subprocess (fallback if in-process fails, e.g. SQLite lock)."""
extract_script = ''' extract_script = '''
import json, sys import json, sys
@@ -237,8 +232,11 @@ print(json.dumps({
sys.exit(1) sys.exit(1)
''' '''
def _run_extract_command(cmd, timeout, label): def _run_extract_command(
# type: (list[str], int, str) -> tuple[Optional[dict], bool] cmd: list[str],
timeout: int,
label: str,
) -> Tuple[Optional[Dict[str, Any]], bool]:
try: try:
result = subprocess.run( result = subprocess.run(
cmd, cmd,
@@ -294,7 +292,7 @@ sys.exit(1)
logger.info("Found cookies in %s (subprocess)", data.get("browser", "unknown")) logger.info("Found cookies in %s (subprocess)", data.get("browser", "unknown"))
# Build full cookie string from all extracted cookies # Build full cookie string from all extracted cookies
cookies = {"auth_token": data["auth_token"], "ct0": data["ct0"]} cookies: Dict[str, str] = {"auth_token": data["auth_token"], "ct0": data["ct0"]}
all_cookies = data.get("all_cookies", {}) all_cookies = data.get("all_cookies", {})
if all_cookies: if all_cookies:
cookie_str = "; ".join("%s=%s" % (k, v) for k, v in all_cookies.items()) cookie_str = "; ".join("%s=%s" % (k, v) for k, v in all_cookies.items())
@@ -331,7 +329,7 @@ def get_cookies() -> Dict[str, str]:
Raises RuntimeError if no cookies found. Raises RuntimeError if no cookies found.
""" """
cookies = None # type: Optional[Dict[str, str]] cookies: Optional[Dict[str, str]] = None
# 1. Try environment variables # 1. Try environment variables
cookies = load_from_env() cookies = load_from_env()

View File

@@ -33,6 +33,7 @@ import sys
import time import time
import urllib.parse import urllib.parse
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
import click import click
from rich.console import Console from rich.console import Console
@@ -49,7 +50,7 @@ from .formatter import (
print_user_profile, print_user_profile,
print_user_table, print_user_table,
) )
from .models import UserProfile from .models import Tweet, UserProfile
from .output import ( from .output import (
default_structured_format, default_structured_format,
emit_error, emit_error,
@@ -68,6 +69,13 @@ from .serialization import (
users_to_data, users_to_data,
) )
ConfigDict = Dict[str, Any]
TweetList = List[Tweet]
FetchTweets = Callable[[int], TweetList]
OptionalPath = Optional[str]
StructuredMode = Optional[str]
WritePayload = Dict[str, Any]
WriteOperation = Callable[[TwitterClient], WritePayload]
console = Console(stderr=True) console = Console(stderr=True)
FEED_TYPES = ["for-you", "following"] FEED_TYPES = ["for-you", "following"]
@@ -145,7 +153,7 @@ def _get_client_for_output(config=None, quiet=False):
def _exit_with_error(exc): def _exit_with_error(exc):
# type: (RuntimeError) -> None # type: (RuntimeError) -> None
if emit_error("api_error", str(exc)): if emit_error(_error_code_for_message(str(exc)), str(exc)):
sys.exit(1) sys.exit(1)
console.print("[red]❌ %s[/red]" % exc) console.print("[red]❌ %s[/red]" % exc)
sys.exit(1) sys.exit(1)
@@ -159,6 +167,20 @@ def _run_guarded(action):
_exit_with_error(exc) _exit_with_error(exc)
def _error_code_for_message(message):
# type: (str) -> str
lowered = message.lower()
if "cookie expired" in lowered or "no twitter cookies found" in lowered or "invalid cookie" in lowered:
return "not_authenticated"
if "rate limited" in lowered or "http 429" in lowered:
return "rate_limited"
if "invalid tweet" in lowered or "required" in lowered or "--max must" in lowered:
return "invalid_input"
if "not found" in lowered:
return "not_found"
return "api_error"
def _resolve_fetch_count(max_count, configured): def _resolve_fetch_count(max_count, configured):
# type: (Optional[int], int) -> int # type: (Optional[int], int) -> int
"""Resolve fetch count with bounds checks.""" """Resolve fetch count with bounds checks."""
@@ -212,6 +234,63 @@ def _apply_filter(tweets, do_filter, config, rich_output=True):
return filtered return filtered
def _structured_mode(as_json: bool, as_yaml: bool) -> StructuredMode:
return default_structured_format(as_json=as_json, as_yaml=as_yaml)
def _emit_mode_payload(payload: object, mode: StructuredMode) -> bool:
if not mode:
return False
emit_structured(payload, as_json=(mode == "json"), as_yaml=(mode == "yaml"))
return True
def _print_lines(lines: List[str], mode: StructuredMode) -> None:
if mode:
return
for line in lines:
console.print(line)
def _handle_structured_runtime_error(
exc: RuntimeError,
*,
mode: StructuredMode,
details: Optional[Dict[str, Any]] = None,
) -> None:
if _emit_mode_payload(
error_payload(_error_code_for_message(str(exc)), str(exc), details=details),
mode,
):
raise SystemExit(1) from None
_exit_with_error(exc)
def _run_write_command(
*,
as_json: bool,
as_yaml: bool,
operation: WriteOperation,
progress_lines: Optional[List[str]] = None,
success_lines: Optional[List[str]] = None,
error_details: Optional[Dict[str, Any]] = None,
) -> Optional[WritePayload]:
mode = _structured_mode(as_json=as_json, as_yaml=as_yaml)
try:
client = _get_client(load_config())
_print_lines(progress_lines or [], mode)
payload = operation(client)
except RuntimeError as exc:
_handle_structured_runtime_error(exc, mode=mode, details=error_details)
return None
if _emit_mode_payload(payload, mode):
return payload
_print_lines(success_lines or ["[green]✅ Done.[/green]"], mode)
return payload
@click.group() @click.group()
@click.option("--verbose", "-v", is_flag=True, help="Enable debug logging.") @click.option("--verbose", "-v", is_flag=True, help="Enable debug logging.")
@click.option("--compact", "-c", is_flag=True, help="Compact output (minimal fields, LLM-friendly).") @click.option("--compact", "-c", is_flag=True, help="Compact output (minimal fields, LLM-friendly).")
@@ -606,103 +685,111 @@ def following(screen_name, max_count, as_json, as_yaml):
# ── Write commands ────────────────────────────────────────────────────── # ── Write commands ──────────────────────────────────────────────────────
def _write_action(emoji, action_desc, client_method, tweet_id): def _write_action(emoji, action_desc, client_method, tweet_id, as_json=False, as_yaml=False):
# type: (str, str, str, str) -> None # type: (str, str, str, str, bool, bool) -> None
"""Generic write action helper to reduce CLI command boilerplate. """Generic write action helper to reduce CLI command boilerplate.
Emits structured JSON/YAML when piped or when OUTPUT env is set. Emits structured JSON/YAML when piped or when OUTPUT env is set.
""" """
try: action_name = action_desc.lower().replace(" ", "_")
config = load_config()
client = _get_client(config) def operation(client: TwitterClient) -> WritePayload:
structured = default_structured_format(as_json=False, as_yaml=False)
if not structured:
console.print("%s %s %s..." % (emoji, action_desc, tweet_id))
getattr(client, client_method)(tweet_id) getattr(client, client_method)(tweet_id)
result = {"success": True, "action": action_desc.lower().replace(" ", "_"), "id": tweet_id} return {"success": True, "action": action_name, "id": tweet_id}
if structured:
emit_structured(result, as_json=(structured == "json"), as_yaml=(structured == "yaml")) _run_write_command(
else: as_json=as_json,
console.print("[green]✅ Done.[/green]") as_yaml=as_yaml,
except RuntimeError as exc: operation=operation,
result = {"success": False, "action": action_desc.lower().replace(" ", "_"), "id": tweet_id, "error": str(exc)} progress_lines=["%s %s %s..." % (emoji, action_desc, tweet_id)],
structured = default_structured_format(as_json=False, as_yaml=False) success_lines=["[green]✅ Done.[/green]"],
if structured: error_details={"action": action_name, "id": tweet_id},
emit_structured(result, as_json=(structured == "json"), as_yaml=(structured == "yaml")) )
sys.exit(1)
_exit_with_error(exc)
@cli.command() @cli.command()
@click.argument("text") @click.argument("text")
@click.option("--reply-to", "-r", default=None, help="Reply to this tweet ID.") @click.option("--reply-to", "-r", default=None, help="Reply to this tweet ID.")
def post(text, reply_to): @structured_output_options
# type: (str, Optional[str]) -> None def post(text, reply_to, as_json, as_yaml):
# type: (str, Optional[str], bool, bool) -> None
"""Post a new tweet. TEXT is the tweet content.""" """Post a new tweet. TEXT is the tweet content."""
config = load_config()
try:
client = _get_client(config)
structured = default_structured_format(as_json=False, as_yaml=False)
if not structured:
action = "Replying to %s" % reply_to if reply_to else "Posting tweet" action = "Replying to %s" % reply_to if reply_to else "Posting tweet"
console.print("✏️ %s..." % action)
def operation(client: TwitterClient) -> WritePayload:
tweet_id = client.create_tweet(text, reply_to_id=reply_to) tweet_id = client.create_tweet(text, reply_to_id=reply_to)
result = {"success": True, "action": "post", "id": tweet_id, "url": "https://x.com/i/status/%s" % tweet_id} return {"success": True, "action": "post", "id": tweet_id, "url": "https://x.com/i/status/%s" % tweet_id}
if structured:
emit_structured(result, as_json=(structured == "json"), as_yaml=(structured == "yaml")) payload = _run_write_command(
else: as_json=as_json,
console.print("[green]✅ Tweet posted![/green]") as_yaml=as_yaml,
console.print("🔗 https://x.com/i/status/%s" % tweet_id) operation=operation,
except RuntimeError as exc: progress_lines=["✏️ %s..." % action],
_exit_with_error(exc) success_lines=["[green]✅ Tweet posted![/green]"],
error_details={"action": "post", "replyTo": reply_to},
)
if payload and not _structured_mode(as_json=as_json, as_yaml=as_yaml):
console.print("🔗 %s" % payload["url"])
@cli.command(name="reply") @cli.command(name="reply")
@click.argument("tweet_id") @click.argument("tweet_id")
@click.argument("text") @click.argument("text")
def reply_tweet(tweet_id, text): @structured_output_options
# type: (str, str) -> None def reply_tweet(tweet_id, text, as_json, as_yaml):
# type: (str, str, bool, bool) -> None
"""Reply to a tweet. TWEET_ID is the tweet to reply to, TEXT is the reply content.""" """Reply to a tweet. TWEET_ID is the tweet to reply to, TEXT is the reply content."""
tweet_id = _normalize_tweet_id(tweet_id) tweet_id = _normalize_tweet_id(tweet_id)
config = load_config() def operation(client: TwitterClient) -> WritePayload:
try:
client = _get_client(config)
structured = default_structured_format(as_json=False, as_yaml=False)
if not structured:
console.print("💬 Replying to %s..." % tweet_id)
new_id = client.create_tweet(text, reply_to_id=tweet_id) new_id = client.create_tweet(text, reply_to_id=tweet_id)
result = {"success": True, "action": "reply", "id": new_id, "replyTo": tweet_id, "url": "https://x.com/i/status/%s" % new_id} return {
if structured: "success": True,
emit_structured(result, as_json=(structured == "json"), as_yaml=(structured == "yaml")) "action": "reply",
else: "id": new_id,
console.print("[green]✅ Reply posted![/green]") "replyTo": tweet_id,
console.print("🔗 https://x.com/i/status/%s" % new_id) "url": "https://x.com/i/status/%s" % new_id,
except RuntimeError as exc: }
_exit_with_error(exc)
payload = _run_write_command(
as_json=as_json,
as_yaml=as_yaml,
operation=operation,
progress_lines=["💬 Replying to %s..." % tweet_id],
success_lines=["[green]✅ Reply posted![/green]"],
error_details={"action": "reply", "replyTo": tweet_id},
)
if payload and not _structured_mode(as_json=as_json, as_yaml=as_yaml):
console.print("🔗 %s" % payload["url"])
@cli.command(name="quote") @cli.command(name="quote")
@click.argument("tweet_id") @click.argument("tweet_id")
@click.argument("text") @click.argument("text")
def quote_tweet(tweet_id, text): @structured_output_options
# type: (str, str) -> None def quote_tweet(tweet_id, text, as_json, as_yaml):
# type: (str, str, bool, bool) -> None
"""Quote-tweet a tweet. TWEET_ID is the tweet to quote, TEXT is the commentary.""" """Quote-tweet a tweet. TWEET_ID is the tweet to quote, TEXT is the commentary."""
tweet_id = _normalize_tweet_id(tweet_id) tweet_id = _normalize_tweet_id(tweet_id)
config = load_config() def operation(client: TwitterClient) -> WritePayload:
try:
client = _get_client(config)
structured = default_structured_format(as_json=False, as_yaml=False)
if not structured:
console.print("🔄 Quoting tweet %s..." % tweet_id)
new_id = client.quote_tweet(tweet_id, text) new_id = client.quote_tweet(tweet_id, text)
result = {"success": True, "action": "quote", "id": new_id, "quotedId": tweet_id, "url": "https://x.com/i/status/%s" % new_id} return {
if structured: "success": True,
emit_structured(result, as_json=(structured == "json"), as_yaml=(structured == "yaml")) "action": "quote",
else: "id": new_id,
console.print("[green]✅ Quote tweet posted![/green]") "quotedId": tweet_id,
console.print("🔗 https://x.com/i/status/%s" % new_id) "url": "https://x.com/i/status/%s" % new_id,
except RuntimeError as exc: }
_exit_with_error(exc)
payload = _run_write_command(
as_json=as_json,
as_yaml=as_yaml,
operation=operation,
progress_lines=["🔄 Quoting tweet %s..." % tweet_id],
success_lines=["[green]✅ Quote tweet posted![/green]"],
error_details={"action": "quote", "quotedId": tweet_id},
)
if payload and not _structured_mode(as_json=as_json, as_yaml=as_yaml):
console.print("🔗 %s" % payload["url"])
@cli.command(name="status") @cli.command(name="status")
@@ -754,125 +841,130 @@ def whoami(as_json, as_yaml):
@cli.command(name="follow") @cli.command(name="follow")
@click.argument("screen_name") @click.argument("screen_name")
def follow_user(screen_name): @structured_output_options
# type: (str,) -> None def follow_user(screen_name, as_json, as_yaml):
# type: (str, bool, bool) -> None
"""Follow a user. SCREEN_NAME is the @handle (without @).""" """Follow a user. SCREEN_NAME is the @handle (without @)."""
screen_name = screen_name.lstrip("@") screen_name = screen_name.lstrip("@")
config = load_config()
try: def operation(client: TwitterClient) -> WritePayload:
client = _get_client(config)
structured = default_structured_format(as_json=False, as_yaml=False)
if not structured:
console.print("👤 Looking up @%s..." % screen_name)
user_id = client.resolve_user_id(screen_name) user_id = client.resolve_user_id(screen_name)
if not structured:
console.print(" Following @%s..." % screen_name)
client.follow_user(user_id) client.follow_user(user_id)
result = {"success": True, "action": "follow", "screenName": screen_name, "userId": user_id} return {"success": True, "action": "follow", "screenName": screen_name, "userId": user_id}
if structured:
emit_structured(result, as_json=(structured == "json"), as_yaml=(structured == "yaml")) _run_write_command(
else: as_json=as_json,
console.print("[green]✅ Now following @%s[/green]" % screen_name) as_yaml=as_yaml,
except RuntimeError as exc: operation=operation,
_exit_with_error(exc) progress_lines=["👤 Looking up @%s..." % screen_name, " Following @%s..." % screen_name],
success_lines=["[green]✅ Now following @%s[/green]" % screen_name],
error_details={"action": "follow", "screenName": screen_name},
)
@cli.command(name="unfollow") @cli.command(name="unfollow")
@click.argument("screen_name") @click.argument("screen_name")
def unfollow_user(screen_name): @structured_output_options
# type: (str,) -> None def unfollow_user(screen_name, as_json, as_yaml):
# type: (str, bool, bool) -> None
"""Unfollow a user. SCREEN_NAME is the @handle (without @).""" """Unfollow a user. SCREEN_NAME is the @handle (without @)."""
screen_name = screen_name.lstrip("@") screen_name = screen_name.lstrip("@")
config = load_config()
try: def operation(client: TwitterClient) -> WritePayload:
client = _get_client(config)
structured = default_structured_format(as_json=False, as_yaml=False)
if not structured:
console.print("👤 Looking up @%s..." % screen_name)
user_id = client.resolve_user_id(screen_name) user_id = client.resolve_user_id(screen_name)
if not structured:
console.print(" Unfollowing @%s..." % screen_name)
client.unfollow_user(user_id) client.unfollow_user(user_id)
result = {"success": True, "action": "unfollow", "screenName": screen_name, "userId": user_id} return {"success": True, "action": "unfollow", "screenName": screen_name, "userId": user_id}
if structured:
emit_structured(result, as_json=(structured == "json"), as_yaml=(structured == "yaml")) _run_write_command(
else: as_json=as_json,
console.print("[green]✅ Unfollowed @%s[/green]" % screen_name) as_yaml=as_yaml,
except RuntimeError as exc: operation=operation,
_exit_with_error(exc) progress_lines=["👤 Looking up @%s..." % screen_name, " Unfollowing @%s..." % screen_name],
success_lines=["[green]✅ Unfollowed @%s[/green]" % screen_name],
error_details={"action": "unfollow", "screenName": screen_name},
)
@cli.command(name="delete") @cli.command(name="delete")
@click.argument("tweet_id") @click.argument("tweet_id")
@click.confirmation_option(prompt="Are you sure you want to delete this tweet?") @click.confirmation_option(prompt="Are you sure you want to delete this tweet?")
def delete_tweet(tweet_id): @structured_output_options
# type: (str,) -> None def delete_tweet(tweet_id, as_json, as_yaml):
# type: (str, bool, bool) -> None
"""Delete a tweet. TWEET_ID is the numeric tweet ID.""" """Delete a tweet. TWEET_ID is the numeric tweet ID."""
_write_action("🗑️", "Deleting tweet", "delete_tweet", tweet_id) _write_action("🗑️", "Deleting tweet", "delete_tweet", tweet_id, as_json=as_json, as_yaml=as_yaml)
@cli.command() @cli.command()
@click.argument("tweet_id") @click.argument("tweet_id")
def like(tweet_id): @structured_output_options
# type: (str,) -> None def like(tweet_id, as_json, as_yaml):
# type: (str, bool, bool) -> None
"""Like a tweet. TWEET_ID is the numeric tweet ID.""" """Like a tweet. TWEET_ID is the numeric tweet ID."""
_write_action("❤️", "Liking tweet", "like_tweet", tweet_id) _write_action("❤️", "Liking tweet", "like_tweet", tweet_id, as_json=as_json, as_yaml=as_yaml)
@cli.command() @cli.command()
@click.argument("tweet_id") @click.argument("tweet_id")
def unlike(tweet_id): @structured_output_options
# type: (str,) -> None def unlike(tweet_id, as_json, as_yaml):
# type: (str, bool, bool) -> None
"""Unlike a tweet. TWEET_ID is the numeric tweet ID.""" """Unlike a tweet. TWEET_ID is the numeric tweet ID."""
_write_action("💔", "Unliking tweet", "unlike_tweet", tweet_id) _write_action("💔", "Unliking tweet", "unlike_tweet", tweet_id, as_json=as_json, as_yaml=as_yaml)
@cli.command() @cli.command()
@click.argument("tweet_id") @click.argument("tweet_id")
def retweet(tweet_id): @structured_output_options
# type: (str,) -> None def retweet(tweet_id, as_json, as_yaml):
# type: (str, bool, bool) -> None
"""Retweet a tweet. TWEET_ID is the numeric tweet ID.""" """Retweet a tweet. TWEET_ID is the numeric tweet ID."""
_write_action("🔄", "Retweeting", "retweet", tweet_id) _write_action("🔄", "Retweeting", "retweet", tweet_id, as_json=as_json, as_yaml=as_yaml)
@cli.command() @cli.command()
@click.argument("tweet_id") @click.argument("tweet_id")
def unretweet(tweet_id): @structured_output_options
# type: (str,) -> None def unretweet(tweet_id, as_json, as_yaml):
# type: (str, bool, bool) -> None
"""Undo a retweet. TWEET_ID is the numeric tweet ID.""" """Undo a retweet. TWEET_ID is the numeric tweet ID."""
_write_action("🔄", "Undoing retweet", "unretweet", tweet_id) _write_action("🔄", "Undoing retweet", "unretweet", tweet_id, as_json=as_json, as_yaml=as_yaml)
@cli.command() @cli.command()
@click.argument("tweet_id") @click.argument("tweet_id")
def favorite(tweet_id): @structured_output_options
# type: (str,) -> None def favorite(tweet_id, as_json, as_yaml):
# type: (str, bool, bool) -> None
"""Bookmark (favorite) a tweet. TWEET_ID is the numeric tweet ID.""" """Bookmark (favorite) a tweet. TWEET_ID is the numeric tweet ID."""
_write_action("🔖", "Bookmarking tweet", "bookmark_tweet", tweet_id) _write_action("🔖", "Bookmarking tweet", "bookmark_tweet", tweet_id, as_json=as_json, as_yaml=as_yaml)
@cli.command() @cli.command()
@click.argument("tweet_id") @click.argument("tweet_id")
def bookmark(tweet_id): @structured_output_options
# type: (str,) -> None def bookmark(tweet_id, as_json, as_yaml):
# type: (str, bool, bool) -> None
"""Bookmark a tweet. TWEET_ID is the numeric tweet ID.""" """Bookmark a tweet. TWEET_ID is the numeric tweet ID."""
_write_action("🔖", "Bookmarking tweet", "bookmark_tweet", tweet_id) _write_action("🔖", "Bookmarking tweet", "bookmark_tweet", tweet_id, as_json=as_json, as_yaml=as_yaml)
@cli.command() @cli.command()
@click.argument("tweet_id") @click.argument("tweet_id")
def unfavorite(tweet_id): @structured_output_options
# type: (str,) -> None def unfavorite(tweet_id, as_json, as_yaml):
# type: (str, bool, bool) -> None
"""Remove a tweet from bookmarks (unfavorite). TWEET_ID is the numeric tweet ID.""" """Remove a tweet from bookmarks (unfavorite). TWEET_ID is the numeric tweet ID."""
_write_action("🔖", "Removing bookmark", "unbookmark_tweet", tweet_id) _write_action("🔖", "Removing bookmark", "unbookmark_tweet", tweet_id, as_json=as_json, as_yaml=as_yaml)
@cli.command() @cli.command()
@click.argument("tweet_id") @click.argument("tweet_id")
def unbookmark(tweet_id): @structured_output_options
# type: (str,) -> None def unbookmark(tweet_id, as_json, as_yaml):
# type: (str, bool, bool) -> None
"""Remove a tweet from bookmarks. TWEET_ID is the numeric tweet ID.""" """Remove a tweet from bookmarks. TWEET_ID is the numeric tweet ID."""
_write_action("🔖", "Removing bookmark", "unbookmark_tweet", tweet_id) _write_action("🔖", "Removing bookmark", "unbookmark_tweet", tweet_id, as_json=as_json, as_yaml=as_yaml)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -10,6 +10,7 @@ import random
import re import re
import time import time
import urllib.parse import urllib.parse
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, cast
import bs4 import bs4
from curl_cffi import requests as _cffi_requests from curl_cffi import requests as _cffi_requests
@@ -34,10 +35,14 @@ from .constants import (
) )
from .models import Author, Metrics, Tweet, TweetMedia, UserProfile from .models import Author, Metrics, Tweet, TweetMedia, UserProfile
TimelineInstructionGetter = Callable[[Any], Any]
TimelineParseResult = Tuple[List[Tweet], Optional[str]]
SeenIdSet = Set[str]
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Shared curl_cffi session — impersonates Chrome 133 TLS/JA3/HTTP2 fingerprint # Shared curl_cffi session — impersonates Chrome 133 TLS/JA3/HTTP2 fingerprint
_cffi_session = None # type: Optional[Any] # lazy init _cffi_session: Optional[Any] = None
FALLBACK_QUERY_IDS = { FALLBACK_QUERY_IDS = {
@@ -94,7 +99,7 @@ _DEFAULT_FEATURES = {
FEATURES = dict(_DEFAULT_FEATURES) FEATURES = dict(_DEFAULT_FEATURES)
# Module-level caches (not thread-safe — CLI is single-threaded) # Module-level caches (not thread-safe — CLI is single-threaded)
_cached_query_ids = {} # type: Dict[str, str] _cached_query_ids: Dict[str, str] = {}
_bundles_scanned = False _bundles_scanned = False
@@ -142,7 +147,7 @@ def _get_cffi_session():
target = _best_chrome_target() target = _best_chrome_target()
sync_chrome_version(target) # align UA/sec-ch-ua with impersonate target sync_chrome_version(target) # align UA/sec-ch-ua with impersonate target
_cffi_session = _cffi_requests.Session( _cffi_session = _cffi_requests.Session(
impersonate=target, impersonate=cast(Any, target),
proxies={"https": proxy, "http": proxy} if proxy else None, proxies={"https": proxy, "http": proxy} if proxy else None,
) )
logger.info("curl_cffi impersonating %s", target) logger.info("curl_cffi impersonating %s", target)
@@ -686,15 +691,16 @@ class TwitterClient:
while len(tweets) < count and attempts < max_attempts: while len(tweets) < count and attempts < max_attempts:
attempts += 1 attempts += 1
variables: Dict[str, Any]
if override_base_variables: if override_base_variables:
variables = {"count": min(count - len(tweets) + 5, 40)} # type: Dict[str, Any] variables = {"count": min(count - len(tweets) + 5, 40)}
else: else:
variables = { variables = {
"count": min(count - len(tweets) + 5, 40), "count": min(count - len(tweets) + 5, 40),
"includePromotedContent": False, "includePromotedContent": False,
"latestControlAvailable": True, "latestControlAvailable": True,
"requestContext": "launch", "requestContext": "launch",
} # type: Dict[str, Any] }
if extra_variables: if extra_variables:
variables.update(extra_variables) variables.update(extra_variables)
if cursor: if cursor:

View File

@@ -5,13 +5,14 @@ from __future__ import annotations
import copy import copy
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Mapping, Optional
import yaml import yaml
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DEFAULT_CONFIG = { DEFAULT_CONFIG: Dict[str, Dict[str, Any]] = {
"fetch": { "fetch": {
"count": 50, "count": 50,
}, },
@@ -35,11 +36,10 @@ DEFAULT_CONFIG = {
"retryBaseDelay": 5.0, "retryBaseDelay": 5.0,
"maxCount": 200, "maxCount": 200,
}, },
} # type: Dict[str, Any] }
def load_config(config_path=None): def load_config(config_path: Optional[str] = None) -> Dict[str, Any]:
# type: (Optional[str]) -> Dict[str, Any]
"""Load and normalize config from YAML, merged with defaults.""" """Load and normalize config from YAML, merged with defaults."""
config = copy.deepcopy(DEFAULT_CONFIG) config = copy.deepcopy(DEFAULT_CONFIG)
path = _resolve_config_path(config_path) path = _resolve_config_path(config_path)
@@ -66,8 +66,7 @@ def load_config(config_path=None):
return _normalize_config(merged) return _normalize_config(merged)
def _resolve_config_path(config_path): def _resolve_config_path(config_path: Optional[str]) -> Optional[Path]:
# type: (Optional[str]) -> Optional[Path]
"""Find config path from explicit argument or default locations.""" """Find config path from explicit argument or default locations."""
if config_path: if config_path:
path = Path(config_path) path = Path(config_path)
@@ -83,8 +82,7 @@ def _resolve_config_path(config_path):
return None return None
def _deep_merge(target, source): def _deep_merge(target: Dict[str, Any], source: Mapping[str, Any]) -> Dict[str, Any]:
# type: (Dict[str, Any], Mapping[str, Any]) -> Dict[str, Any]
"""Deep merge source into target (source values override target).""" """Deep merge source into target (source values override target)."""
result = copy.deepcopy(target) result = copy.deepcopy(target)
for key, value in source.items(): for key, value in source.items():
@@ -95,8 +93,7 @@ def _deep_merge(target, source):
return result return result
def _normalize_config(config): def _normalize_config(config: Dict[str, Any]) -> Dict[str, Any]:
# type: (Dict[str, Any]) -> Dict[str, Any]
"""Normalize shape and value types.""" """Normalize shape and value types."""
normalized = copy.deepcopy(DEFAULT_CONFIG) normalized = copy.deepcopy(DEFAULT_CONFIG)
merged = _deep_merge(normalized, config) merged = _deep_merge(normalized, config)
@@ -148,8 +145,7 @@ def _normalize_config(config):
return merged return merged
def _as_int(value, default): def _as_int(value: Any, default: int) -> int:
# type: (Any, int) -> int
"""Best-effort int conversion.""" """Best-effort int conversion."""
try: try:
return int(value) return int(value)
@@ -157,8 +153,7 @@ def _as_int(value, default):
return default return default
def _as_float(value, default): def _as_float(value: Any, default: float) -> float:
# type: (Any, float) -> float
"""Best-effort float conversion.""" """Best-effort float conversion."""
try: try:
return float(value) return float(value)

View File

@@ -8,8 +8,10 @@ from __future__ import annotations
from dataclasses import replace from dataclasses import replace
import math import math
from typing import Any, Dict, List, Mapping, Optional, Sequence
from .config import _as_float, _as_int from .config import _as_float, _as_int
from .models import Tweet
DEFAULT_WEIGHTS = { DEFAULT_WEIGHTS = {
"likes": 1.0, "likes": 1.0,
@@ -20,8 +22,7 @@ DEFAULT_WEIGHTS = {
} }
def score_tweet(tweet, weights=None): def score_tweet(tweet: Tweet, weights: Optional[Dict[str, float]] = None) -> float:
# type: (Tweet, Optional[Dict[str, float]]) -> float
"""Calculate engagement score for a single tweet. """Calculate engagement score for a single tweet.
Formula: Formula:
@@ -45,8 +46,7 @@ def score_tweet(tweet, weights=None):
) )
def filter_tweets(tweets, config): def filter_tweets(tweets: Sequence[Tweet], config: Mapping[str, Any]) -> List[Tweet]:
# type: (Sequence[Tweet], Mapping[str, Any]) -> List[Tweet]
"""Filter and rank tweets according to config. """Filter and rank tweets according to config.
Config keys: Config keys:
@@ -74,7 +74,7 @@ def filter_tweets(tweets, config):
scored = [replace(tweet, score=round(score_tweet(tweet, weights), 1)) for tweet in filtered] scored = [replace(tweet, score=round(score_tweet(tweet, weights), 1)) for tweet in filtered]
# 4. Sort by score (descending) # 4. Sort by score (descending)
scored.sort(key=lambda tweet: tweet.score, reverse=True) scored.sort(key=lambda tweet: tweet.score or 0.0, reverse=True)
# 5. Apply filter mode # 5. Apply filter mode
mode = str(config.get("mode", "topN")) mode = str(config.get("mode", "topN"))
@@ -83,12 +83,11 @@ def filter_tweets(tweets, config):
return scored[:top_n] return scored[:top_n]
if mode == "score": if mode == "score":
min_score = _as_float(config.get("minScore"), 50.0) min_score = _as_float(config.get("minScore"), 50.0)
return [tweet for tweet in scored if tweet.score >= min_score] return [tweet for tweet in scored if (tweet.score or 0.0) >= min_score]
return scored return scored
def _build_weights(raw_weights): def _build_weights(raw_weights: Mapping[str, Any]) -> Dict[str, float]:
# type: (Mapping[str, Any]) -> Dict[str, float]
"""Merge custom weights with defaults and coerce to float.""" """Merge custom weights with defaults and coerce to float."""
merged = {} merged = {}
for key, default_value in DEFAULT_WEIGHTS.items(): for key, default_value in DEFAULT_WEIGHTS.items():

View File

@@ -2,14 +2,16 @@
from __future__ import annotations from __future__ import annotations
from typing import List, Optional
from rich.console import Console from rich.console import Console
from rich.panel import Panel from rich.panel import Panel
from rich.table import Table from rich.table import Table
from .models import Tweet, UserProfile
def format_number(n):
# type: (int) -> str def format_number(n: int) -> str:
"""Format number with K/M suffixes.""" """Format number with K/M suffixes."""
if n >= 1_000_000: if n >= 1_000_000:
return "%.1fM" % (n / 1_000_000) return "%.1fM" % (n / 1_000_000)
@@ -18,8 +20,11 @@ def format_number(n):
return str(n) return str(n)
def print_tweet_table(tweets, console=None, title=None): def print_tweet_table(
# type: (List[Tweet], Optional[Console], Optional[str]) -> None tweets: List[Tweet],
console: Optional[Console] = None,
title: Optional[str] = None,
) -> None:
"""Print tweets as a rich table.""" """Print tweets as a rich table."""
if console is None: if console is None:
console = Console() console = Console()
@@ -86,8 +91,7 @@ def print_tweet_table(tweets, console=None, title=None):
console.print(table) console.print(table)
def print_tweet_detail(tweet, console=None): def print_tweet_detail(tweet: Tweet, console: Optional[Console] = None) -> None:
# type: (Tweet, Optional[Console]) -> None
"""Print a single tweet in detail using a rich panel.""" """Print a single tweet in detail using a rich panel."""
if console is None: if console is None:
console = Console() console = Console()
@@ -143,8 +147,11 @@ def print_tweet_detail(tweet, console=None):
)) ))
def print_filter_stats(original_count, filtered, console=None): def print_filter_stats(
# type: (int, List[Tweet], Optional[Console]) -> None original_count: int,
filtered: List[Tweet],
console: Optional[Console] = None,
) -> None:
"""Print filter statistics.""" """Print filter statistics."""
if console is None: if console is None:
console = Console() console = Console()
@@ -153,14 +160,14 @@ def print_filter_stats(original_count, filtered, console=None):
"📊 Filter: %d%d tweets" % (original_count, len(filtered)) "📊 Filter: %d%d tweets" % (original_count, len(filtered))
) )
if filtered: if filtered:
top_score = filtered[0].score top_score = filtered[0].score or 0.0
bottom_score = filtered[-1].score bottom_score = filtered[-1].score or 0.0
console.print( console.print(
" Score range: %.1f ~ %.1f" % (bottom_score, top_score) " Score range: %.1f ~ %.1f" % (bottom_score, top_score)
) )
def print_user_profile(user, console=None):
# type: (UserProfile, Optional[Console]) -> None def print_user_profile(user: UserProfile, console: Optional[Console] = None) -> None:
"""Print user profile as a rich panel.""" """Print user profile as a rich panel."""
if console is None: if console is None:
console = Console() console = Console()
@@ -202,8 +209,11 @@ def print_user_profile(user, console=None):
)) ))
def print_user_table(users, console=None, title=None): def print_user_table(
# type: (List[UserProfile], Optional[Console], Optional[str]) -> None users: List[UserProfile],
console: Optional[Console] = None,
title: Optional[str] = None,
) -> None:
"""Print a list of users as a rich table.""" """Print a list of users as a rich table."""
if console is None: if console is None:
console = Console() console = Console()

90
uv.lock generated
View File

@@ -541,6 +541,66 @@ 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" }, { 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 = "mypy"
version = "1.14.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mypy-extensions" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
{ 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'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b9/eb/2c92d8ea1e684440f54fa49ac5d9a5f19967b7b472a281f419e69a8d228e/mypy-1.14.1.tar.gz", hash = "sha256:7ec88144fe9b510e8475ec2f5f251992690fcf89ccb4500b214b4226abcd32d6", size = 3216051, upload-time = "2024-12-30T16:39:07.335Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/7a/87ae2adb31d68402da6da1e5f30c07ea6063e9f09b5e7cfc9dfa44075e74/mypy-1.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:52686e37cf13d559f668aa398dd7ddf1f92c5d613e4f8cb262be2fb4fedb0fcb", size = 11211002, upload-time = "2024-12-30T16:37:22.435Z" },
{ url = "https://files.pythonhosted.org/packages/e1/23/eada4c38608b444618a132be0d199b280049ded278b24cbb9d3fc59658e4/mypy-1.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1fb545ca340537d4b45d3eecdb3def05e913299ca72c290326be19b3804b39c0", size = 10358400, upload-time = "2024-12-30T16:37:53.526Z" },
{ url = "https://files.pythonhosted.org/packages/43/c9/d6785c6f66241c62fd2992b05057f404237deaad1566545e9f144ced07f5/mypy-1.14.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90716d8b2d1f4cd503309788e51366f07c56635a3309b0f6a32547eaaa36a64d", size = 12095172, upload-time = "2024-12-30T16:37:50.332Z" },
{ url = "https://files.pythonhosted.org/packages/c3/62/daa7e787770c83c52ce2aaf1a111eae5893de9e004743f51bfcad9e487ec/mypy-1.14.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ae753f5c9fef278bcf12e1a564351764f2a6da579d4a81347e1d5a15819997b", size = 12828732, upload-time = "2024-12-30T16:37:29.96Z" },
{ url = "https://files.pythonhosted.org/packages/1b/a2/5fb18318a3637f29f16f4e41340b795da14f4751ef4f51c99ff39ab62e52/mypy-1.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0fe0f5feaafcb04505bcf439e991c6d8f1bf8b15f12b05feeed96e9e7bf1427", size = 13012197, upload-time = "2024-12-30T16:38:05.037Z" },
{ url = "https://files.pythonhosted.org/packages/28/99/e153ce39105d164b5f02c06c35c7ba958aaff50a2babba7d080988b03fe7/mypy-1.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:7d54bd85b925e501c555a3227f3ec0cfc54ee8b6930bd6141ec872d1c572f81f", size = 9780836, upload-time = "2024-12-30T16:37:19.726Z" },
{ url = "https://files.pythonhosted.org/packages/da/11/a9422850fd506edbcdc7f6090682ecceaf1f87b9dd847f9df79942da8506/mypy-1.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f995e511de847791c3b11ed90084a7a0aafdc074ab88c5a9711622fe4751138c", size = 11120432, upload-time = "2024-12-30T16:37:11.533Z" },
{ url = "https://files.pythonhosted.org/packages/b6/9e/47e450fd39078d9c02d620545b2cb37993a8a8bdf7db3652ace2f80521ca/mypy-1.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d64169ec3b8461311f8ce2fd2eb5d33e2d0f2c7b49116259c51d0d96edee48d1", size = 10279515, upload-time = "2024-12-30T16:37:40.724Z" },
{ url = "https://files.pythonhosted.org/packages/01/b5/6c8d33bd0f851a7692a8bfe4ee75eb82b6983a3cf39e5e32a5d2a723f0c1/mypy-1.14.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba24549de7b89b6381b91fbc068d798192b1b5201987070319889e93038967a8", size = 12025791, upload-time = "2024-12-30T16:36:58.73Z" },
{ url = "https://files.pythonhosted.org/packages/f0/4c/e10e2c46ea37cab5c471d0ddaaa9a434dc1d28650078ac1b56c2d7b9b2e4/mypy-1.14.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:183cf0a45457d28ff9d758730cd0210419ac27d4d3f285beda038c9083363b1f", size = 12749203, upload-time = "2024-12-30T16:37:03.741Z" },
{ url = "https://files.pythonhosted.org/packages/88/55/beacb0c69beab2153a0f57671ec07861d27d735a0faff135a494cd4f5020/mypy-1.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f2a0ecc86378f45347f586e4163d1769dd81c5a223d577fe351f26b179e148b1", size = 12885900, upload-time = "2024-12-30T16:37:57.948Z" },
{ url = "https://files.pythonhosted.org/packages/a2/75/8c93ff7f315c4d086a2dfcde02f713004357d70a163eddb6c56a6a5eff40/mypy-1.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:ad3301ebebec9e8ee7135d8e3109ca76c23752bac1e717bc84cd3836b4bf3eae", size = 9777869, upload-time = "2024-12-30T16:37:33.428Z" },
{ url = "https://files.pythonhosted.org/packages/43/1b/b38c079609bb4627905b74fc6a49849835acf68547ac33d8ceb707de5f52/mypy-1.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30ff5ef8519bbc2e18b3b54521ec319513a26f1bba19a7582e7b1f58a6e69f14", size = 11266668, upload-time = "2024-12-30T16:38:02.211Z" },
{ url = "https://files.pythonhosted.org/packages/6b/75/2ed0d2964c1ffc9971c729f7a544e9cd34b2cdabbe2d11afd148d7838aa2/mypy-1.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cb9f255c18052343c70234907e2e532bc7e55a62565d64536dbc7706a20b78b9", size = 10254060, upload-time = "2024-12-30T16:37:46.131Z" },
{ url = "https://files.pythonhosted.org/packages/a1/5f/7b8051552d4da3c51bbe8fcafffd76a6823779101a2b198d80886cd8f08e/mypy-1.14.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b4e3413e0bddea671012b063e27591b953d653209e7a4fa5e48759cda77ca11", size = 11933167, upload-time = "2024-12-30T16:37:43.534Z" },
{ url = "https://files.pythonhosted.org/packages/04/90/f53971d3ac39d8b68bbaab9a4c6c58c8caa4d5fd3d587d16f5927eeeabe1/mypy-1.14.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:553c293b1fbdebb6c3c4030589dab9fafb6dfa768995a453d8a5d3b23784af2e", size = 12864341, upload-time = "2024-12-30T16:37:36.249Z" },
{ url = "https://files.pythonhosted.org/packages/03/d2/8bc0aeaaf2e88c977db41583559319f1821c069e943ada2701e86d0430b7/mypy-1.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fad79bfe3b65fe6a1efaed97b445c3d37f7be9fdc348bdb2d7cac75579607c89", size = 12972991, upload-time = "2024-12-30T16:37:06.743Z" },
{ url = "https://files.pythonhosted.org/packages/6f/17/07815114b903b49b0f2cf7499f1c130e5aa459411596668267535fe9243c/mypy-1.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:8fa2220e54d2946e94ab6dbb3ba0a992795bd68b16dc852db33028df2b00191b", size = 9879016, upload-time = "2024-12-30T16:37:15.02Z" },
{ url = "https://files.pythonhosted.org/packages/9e/15/bb6a686901f59222275ab228453de741185f9d54fecbaacec041679496c6/mypy-1.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:92c3ed5afb06c3a8e188cb5da4984cab9ec9a77ba956ee419c68a388b4595255", size = 11252097, upload-time = "2024-12-30T16:37:25.144Z" },
{ url = "https://files.pythonhosted.org/packages/f8/b3/8b0f74dfd072c802b7fa368829defdf3ee1566ba74c32a2cb2403f68024c/mypy-1.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dbec574648b3e25f43d23577309b16534431db4ddc09fda50841f1e34e64ed34", size = 10239728, upload-time = "2024-12-30T16:38:08.634Z" },
{ url = "https://files.pythonhosted.org/packages/c5/9b/4fd95ab20c52bb5b8c03cc49169be5905d931de17edfe4d9d2986800b52e/mypy-1.14.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c6d94b16d62eb3e947281aa7347d78236688e21081f11de976376cf010eb31a", size = 11924965, upload-time = "2024-12-30T16:38:12.132Z" },
{ url = "https://files.pythonhosted.org/packages/56/9d/4a236b9c57f5d8f08ed346914b3f091a62dd7e19336b2b2a0d85485f82ff/mypy-1.14.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4b19b03fdf54f3c5b2fa474c56b4c13c9dbfb9a2db4370ede7ec11a2c5927d9", size = 12867660, upload-time = "2024-12-30T16:38:17.342Z" },
{ url = "https://files.pythonhosted.org/packages/40/88/a61a5497e2f68d9027de2bb139c7bb9abaeb1be1584649fa9d807f80a338/mypy-1.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0c911fde686394753fff899c409fd4e16e9b294c24bfd5e1ea4675deae1ac6fd", size = 12969198, upload-time = "2024-12-30T16:38:32.839Z" },
{ url = "https://files.pythonhosted.org/packages/54/da/3d6fc5d92d324701b0c23fb413c853892bfe0e1dbe06c9138037d459756b/mypy-1.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:8b21525cb51671219f5307be85f7e646a153e5acc656e5cebf64bfa076c50107", size = 9885276, upload-time = "2024-12-30T16:38:20.828Z" },
{ url = "https://files.pythonhosted.org/packages/39/02/1817328c1372be57c16148ce7d2bfcfa4a796bedaed897381b1aad9b267c/mypy-1.14.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7084fb8f1128c76cd9cf68fe5971b37072598e7c31b2f9f95586b65c741a9d31", size = 11143050, upload-time = "2024-12-30T16:38:29.743Z" },
{ url = "https://files.pythonhosted.org/packages/b9/07/99db9a95ece5e58eee1dd87ca456a7e7b5ced6798fd78182c59c35a7587b/mypy-1.14.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8f845a00b4f420f693f870eaee5f3e2692fa84cc8514496114649cfa8fd5e2c6", size = 10321087, upload-time = "2024-12-30T16:38:14.739Z" },
{ url = "https://files.pythonhosted.org/packages/9a/eb/85ea6086227b84bce79b3baf7f465b4732e0785830726ce4a51528173b71/mypy-1.14.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44bf464499f0e3a2d14d58b54674dee25c031703b2ffc35064bd0df2e0fac319", size = 12066766, upload-time = "2024-12-30T16:38:47.038Z" },
{ url = "https://files.pythonhosted.org/packages/4b/bb/f01bebf76811475d66359c259eabe40766d2f8ac8b8250d4e224bb6df379/mypy-1.14.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c99f27732c0b7dc847adb21c9d47ce57eb48fa33a17bc6d7d5c5e9f9e7ae5bac", size = 12787111, upload-time = "2024-12-30T16:39:02.444Z" },
{ url = "https://files.pythonhosted.org/packages/2f/c9/84837ff891edcb6dcc3c27d85ea52aab0c4a34740ff5f0ccc0eb87c56139/mypy-1.14.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:bce23c7377b43602baa0bd22ea3265c49b9ff0b76eb315d6c34721af4cdf1d9b", size = 12974331, upload-time = "2024-12-30T16:38:23.849Z" },
{ url = "https://files.pythonhosted.org/packages/84/5f/901e18464e6a13f8949b4909535be3fa7f823291b8ab4e4b36cfe57d6769/mypy-1.14.1-cp38-cp38-win_amd64.whl", hash = "sha256:8edc07eeade7ebc771ff9cf6b211b9a7d93687ff892150cb5692e4f4272b0837", size = 9763210, upload-time = "2024-12-30T16:38:36.299Z" },
{ url = "https://files.pythonhosted.org/packages/ca/1f/186d133ae2514633f8558e78cd658070ba686c0e9275c5a5c24a1e1f0d67/mypy-1.14.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3888a1816d69f7ab92092f785a462944b3ca16d7c470d564165fe703b0970c35", size = 11200493, upload-time = "2024-12-30T16:38:26.935Z" },
{ url = "https://files.pythonhosted.org/packages/af/fc/4842485d034e38a4646cccd1369f6b1ccd7bc86989c52770d75d719a9941/mypy-1.14.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:46c756a444117c43ee984bd055db99e498bc613a70bbbc120272bd13ca579fbc", size = 10357702, upload-time = "2024-12-30T16:38:50.623Z" },
{ url = "https://files.pythonhosted.org/packages/b4/e6/457b83f2d701e23869cfec013a48a12638f75b9d37612a9ddf99072c1051/mypy-1.14.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:27fc248022907e72abfd8e22ab1f10e903915ff69961174784a3900a8cba9ad9", size = 12091104, upload-time = "2024-12-30T16:38:53.735Z" },
{ url = "https://files.pythonhosted.org/packages/f1/bf/76a569158db678fee59f4fd30b8e7a0d75bcbaeef49edd882a0d63af6d66/mypy-1.14.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:499d6a72fb7e5de92218db961f1a66d5f11783f9ae549d214617edab5d4dbdbb", size = 12830167, upload-time = "2024-12-30T16:38:56.437Z" },
{ url = "https://files.pythonhosted.org/packages/43/bc/0bc6b694b3103de9fed61867f1c8bd33336b913d16831431e7cb48ef1c92/mypy-1.14.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:57961db9795eb566dc1d1b4e9139ebc4c6b0cb6e7254ecde69d1552bf7613f60", size = 13013834, upload-time = "2024-12-30T16:38:59.204Z" },
{ url = "https://files.pythonhosted.org/packages/b0/79/5f5ec47849b6df1e6943d5fd8e6632fbfc04b4fd4acfa5a5a9535d11b4e2/mypy-1.14.1-cp39-cp39-win_amd64.whl", hash = "sha256:07ba89fdcc9451f2ebb02853deb6aaaa3d2239a236669a63ab3801bbf923ef5c", size = 9781231, upload-time = "2024-12-30T16:39:05.124Z" },
{ url = "https://files.pythonhosted.org/packages/a0/b5/32dd67b69a16d088e533962e5044e51004176a9952419de0370cdaead0f8/mypy-1.14.1-py3-none-any.whl", hash = "sha256:b66a60cc4073aeb8ae00057f9c1f64d49e90f918fbcef9a977eb121da8b8f1d1", size = 2752905, upload-time = "2024-12-30T16:38:42.021Z" },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "26.0" version = "26.0"
@@ -967,10 +1027,13 @@ dependencies = [
[package.optional-dependencies] [package.optional-dependencies]
dev = [ dev = [
{ name = "mypy" },
{ name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { 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 = "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 = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "ruff" }, { name = "ruff" },
{ name = "types-pyyaml", version = "6.0.12.20241230", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
{ name = "types-pyyaml", version = "6.0.12.20250915", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
] ]
[package.metadata] [package.metadata]
@@ -979,14 +1042,41 @@ requires-dist = [
{ name = "browser-cookie3", specifier = ">=0.19" }, { name = "browser-cookie3", specifier = ">=0.19" },
{ name = "click", specifier = ">=8.0" }, { name = "click", specifier = ">=8.0" },
{ name = "curl-cffi", specifier = ">=0.7" }, { name = "curl-cffi", specifier = ">=0.7" },
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.14,<1.15" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
{ name = "pyyaml", specifier = ">=6.0" }, { name = "pyyaml", specifier = ">=6.0" },
{ name = "rich", specifier = ">=13.0" }, { name = "rich", specifier = ">=13.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8" },
{ name = "types-pyyaml", marker = "extra == 'dev'", specifier = ">=6.0.12" },
{ name = "xclienttransaction", specifier = ">=1.0.1" }, { name = "xclienttransaction", specifier = ">=1.0.1" },
] ]
provides-extras = ["dev"] provides-extras = ["dev"]
[[package]]
name = "types-pyyaml"
version = "6.0.12.20241230"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.9'",
]
sdist = { url = "https://files.pythonhosted.org/packages/9a/f9/4d566925bcf9396136c0a2e5dc7e230ff08d86fa011a69888dd184469d80/types_pyyaml-6.0.12.20241230.tar.gz", hash = "sha256:7f07622dbd34bb9c8b264fe860a17e0efcad00d50b5f27e93984909d9363498c", size = 17078, upload-time = "2024-12-30T02:44:38.168Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/c1/48474fbead512b70ccdb4f81ba5eb4a58f69d100ba19f17c92c0c4f50ae6/types_PyYAML-6.0.12.20241230-py3-none-any.whl", hash = "sha256:fa4d32565219b68e6dee5f67534c722e53c00d1cfc09c435ef04d7353e1e96e6", size = 20029, upload-time = "2024-12-30T02:44:36.162Z" },
]
[[package]]
name = "types-pyyaml"
version = "6.0.12.20250915"
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/7e/69/3c51b36d04da19b92f9e815be12753125bd8bc247ba0470a982e6979e71c/types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3", size = 17522, upload-time = "2025-09-15T03:01:00.728Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6", size = 20338, upload-time = "2025-09-15T03:00:59.218Z" },
]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.13.2" version = "4.13.2"