refactor: harden client, clean tests, and fix timeouts

- Security & validation: tighten hash regex, add HTTP timeouts to all
  requests, pass client.timeout through get_fragment_hash and AsyncClient
- Constants: move all constants to types/constants.py; remove re-exports
  from types/__init__.py; add DEFAULT_TIMEOUT, REQUIRED_COOKIE_KEYS
- FragmentClient: add timeout param (default 30 s); async-context-manager
  support; remove WALLET_CLASSES from public API
- Exceptions: remove dead INVALID_USERNAME constant (Fragment validates
  server-side); keep full hierarchy intact
- Tests: add 006_test_methods_mock.py (6 mock tests for all 3 methods);
  DRY-refactor 004_test_balance.py (_patch_wallet context manager);
  clean up 005_test_methods.py (remove fragile network test, rename tests)
- Examples: switch all 4 examples to async-with; align error messages;
  replace %-format with f-strings
- README: rewrite usage section with single comprehensive async-with
  example covering all 3 methods and full exception hierarchy
- CI: add mypy step to lint job; add pytest-mock and mypy to dev deps;
  set FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 on all jobs; fix COOKIES_JSON
  to job-level env var
This commit is contained in:
bohd4nx
2026-03-20 20:16:43 +02:00
parent c5edfad06f
commit 3e14a01c92
22 changed files with 335 additions and 309 deletions
+4 -3
View File
@@ -4,16 +4,14 @@ from pyfragment.methods.premium import purchase_premium
from pyfragment.methods.stars import purchase_stars
from pyfragment.methods.ton import topup_ton
from pyfragment.types import (
REQUIRED_COOKIE_KEYS,
SUPPORTED_WALLET_VERSIONS,
AdsTopupResult,
ConfigurationError,
CookieError,
PremiumResult,
StarsResult,
WalletInfo,
WalletVersion,
)
from pyfragment.types.constants import DEFAULT_TIMEOUT, REQUIRED_COOKIE_KEYS, SUPPORTED_WALLET_VERSIONS, WalletVersion
from pyfragment.utils.wallet import get_wallet_info
@@ -30,6 +28,7 @@ class FragmentClient:
api_key: Tonapi API key — get one at https://tonconsole.com.
cookies: Fragment session cookies as a dict or JSON string.
wallet_version: Wallet contract version — ``"V4R2"`` or ``"V5R1"`` (default).
timeout: HTTP request timeout in seconds. Defaults to ``30.0``.
Raises:
ConfigurationError: If ``seed``, ``api_key``, or ``wallet_version`` are missing or invalid.
@@ -53,6 +52,7 @@ class FragmentClient:
api_key: str,
cookies: dict | str,
wallet_version: str = "V5R1",
timeout: float = DEFAULT_TIMEOUT,
) -> None:
missing = [name for name, val in (("seed", seed), ("api_key", api_key)) if not val or not str(val).strip()]
if missing:
@@ -87,6 +87,7 @@ class FragmentClient:
self.api_key: str = api_key.strip()
self.cookies: dict = cookies
self.wallet_version: WalletVersion = version # type: ignore[assignment]
self.timeout: float = timeout
async def __aenter__(self) -> "FragmentClient":
return self
+3 -5
View File
@@ -5,9 +5,6 @@ from typing import TYPE_CHECKING
import httpx
from pyfragment.types import (
BASE_HEADERS,
DEVICE,
PREMIUM_PAGE,
ConfigurationError,
FragmentAPIError,
FragmentError,
@@ -15,6 +12,7 @@ from pyfragment.types import (
UnexpectedError,
UserNotFoundError,
)
from pyfragment.types.constants import BASE_HEADERS, DEVICE, PREMIUM_PAGE
from pyfragment.utils import (
execute_transaction_request,
fragment_post,
@@ -94,10 +92,10 @@ async def purchase_premium(client: "FragmentClient", username: str, months: int,
raise ConfigurationError(ConfigurationError.INVALID_MONTHS)
try:
fragment_hash = await get_fragment_hash(client.cookies, HEADERS, PREMIUM_PAGE)
fragment_hash = await get_fragment_hash(client.cookies, HEADERS, PREMIUM_PAGE, client.timeout)
account = await get_account_info(client)
async with httpx.AsyncClient(cookies=client.cookies) as session:
async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session:
recipient = await _search_recipient(session, fragment_hash, username, months)
req_id = await _init_request(session, fragment_hash, recipient, months)
+3 -5
View File
@@ -4,9 +4,6 @@ from typing import TYPE_CHECKING
import httpx
from pyfragment.types import (
BASE_HEADERS,
DEVICE,
STARS_PAGE,
ConfigurationError,
FragmentAPIError,
FragmentError,
@@ -14,6 +11,7 @@ from pyfragment.types import (
UnexpectedError,
UserNotFoundError,
)
from pyfragment.types.constants import BASE_HEADERS, DEVICE, STARS_PAGE
from pyfragment.utils import (
execute_transaction_request,
fragment_post,
@@ -81,10 +79,10 @@ async def purchase_stars(client: "FragmentClient", username: str, amount: int, s
raise ConfigurationError(ConfigurationError.INVALID_STARS_AMOUNT)
try:
fragment_hash = await get_fragment_hash(client.cookies, HEADERS, STARS_PAGE)
fragment_hash = await get_fragment_hash(client.cookies, HEADERS, STARS_PAGE, client.timeout)
account = await get_account_info(client)
async with httpx.AsyncClient(cookies=client.cookies) as session:
async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session:
recipient = await _search_recipient(session, fragment_hash, username)
req_id = await _init_request(session, fragment_hash, recipient, amount)
+3 -5
View File
@@ -4,9 +4,6 @@ from typing import TYPE_CHECKING
import httpx
from pyfragment.types import (
BASE_HEADERS,
DEVICE,
TON_PAGE,
AdsTopupResult,
ConfigurationError,
FragmentAPIError,
@@ -14,6 +11,7 @@ from pyfragment.types import (
UnexpectedError,
UserNotFoundError,
)
from pyfragment.types.constants import BASE_HEADERS, DEVICE, TON_PAGE
from pyfragment.utils import (
execute_transaction_request,
fragment_post,
@@ -81,10 +79,10 @@ async def topup_ton(client: "FragmentClient", username: str, amount: int, show_s
raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT)
try:
fragment_hash = await get_fragment_hash(client.cookies, HEADERS, TON_PAGE)
fragment_hash = await get_fragment_hash(client.cookies, HEADERS, TON_PAGE, client.timeout)
account = await get_account_info(client)
async with httpx.AsyncClient(cookies=client.cookies) as session:
async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session:
recipient = await _search_recipient(session, fragment_hash, username)
req_id = await _init_request(session, fragment_hash, recipient, amount)
-23
View File
@@ -1,15 +1,3 @@
from pyfragment.types.constants import (
BASE_HEADERS,
DEVICE,
MIN_TON_BALANCE,
PREMIUM_PAGE,
REQUIRED_COOKIE_KEYS,
STARS_PAGE,
SUPPORTED_WALLET_VERSIONS,
TON_PAGE,
WALLET_CLASSES,
WalletVersion,
)
from pyfragment.types.exceptions import (
ClientError,
ConfigurationError,
@@ -28,17 +16,6 @@ from pyfragment.types.exceptions import (
from pyfragment.types.results import AdsTopupResult, PremiumResult, StarsResult, WalletInfo
__all__ = [
# constants
"BASE_HEADERS",
"DEVICE",
"MIN_TON_BALANCE",
"PREMIUM_PAGE",
"REQUIRED_COOKIE_KEYS",
"STARS_PAGE",
"SUPPORTED_WALLET_VERSIONS",
"TON_PAGE",
"WALLET_CLASSES",
"WalletVersion",
# client exceptions
"ClientError",
"ConfigurationError",
+3
View File
@@ -13,6 +13,9 @@ WALLET_CLASSES: dict[str, type] = {"V4R2": WalletV4R2, "V5R1": WalletV5R1}
# Minimum wallet balance required to cover TON network gas fees.
MIN_TON_BALANCE: float = 0.056
# Default HTTP request timeout in seconds.
DEFAULT_TIMEOUT: float = 30.0
# Required Fragment session cookie keys
REQUIRED_COOKIE_KEYS: tuple[str, ...] = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token")
+3
View File
@@ -16,6 +16,9 @@ class ConfigurationError(ClientError):
INVALID_MONTHS = "Invalid duration. Choose 3, 6, or 12 months."
INVALID_STARS_AMOUNT = "Amount must be an integer between 50 and 1 000 000 stars."
INVALID_TON_AMOUNT = "Amount must be an integer between 1 and 1 000 000 000 TON."
INVALID_USERNAME = (
"Invalid username '{username}'. Must be 532 characters: letters (AZ, az), digits (09), or underscores (_)."
)
class CookieError(ClientError):
+4 -1
View File
@@ -4,12 +4,14 @@ from typing import Any
import httpx
from pyfragment.types import FragmentPageError, ParseError, VerificationError
from pyfragment.types.constants import DEFAULT_TIMEOUT
async def get_fragment_hash(
cookies: dict[str, Any],
headers: dict[str, str],
page_url: str,
timeout: float = DEFAULT_TIMEOUT,
) -> str:
"""Fetch the API hash from a Fragment page.
@@ -21,6 +23,7 @@ async def get_fragment_hash(
cookies: Active Fragment session cookies.
headers: Base headers for the relevant Fragment page.
page_url: URL of the Fragment page to fetch the hash from.
timeout: HTTP request timeout in seconds. Defaults to ``DEFAULT_TIMEOUT``.
Returns:
Lowercase hex hash string.
@@ -44,7 +47,7 @@ async def get_fragment_hash(
}
)
async with httpx.AsyncClient(cookies=cookies) as session:
async with httpx.AsyncClient(cookies=cookies, timeout=timeout) as session:
response = await session.get(page_url, headers=page_headers)
if response.status_code != 200:
+2 -1
View File
@@ -6,7 +6,8 @@ from tonutils.clients import TonapiClient
from tonutils.exceptions import ProviderResponseError
from tonutils.types import NetworkGlobalID
from pyfragment.types import MIN_TON_BALANCE, WALLET_CLASSES, TransactionError, WalletError
from pyfragment.types import TransactionError, WalletError
from pyfragment.types.constants import MIN_TON_BALANCE, WALLET_CLASSES
from pyfragment.types.results import WalletInfo
from pyfragment.utils.decoder import clean_decode