Merge pull request #26 from bohd4nx/dev

Dev
This commit is contained in:
Bohdan 👾
2026-05-29 01:58:54 +03:00
committed by GitHub
28 changed files with 603 additions and 347 deletions
+6 -8
View File
@@ -2,16 +2,14 @@ name: CI
on: on:
push: push:
branches: [ "**" ] branches: ["**"]
pull_request: pull_request:
branches: [ "**" ] branches: ["**"]
jobs: jobs:
lint: lint:
name: Lint & Format name: Lint & Format
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
@@ -19,21 +17,20 @@ jobs:
- uses: actions/setup-python@v6 - uses: actions/setup-python@v6
with: with:
python-version: "3.10" python-version: "3.10"
cache: pip
- run: pip install ".[dev]" - run: pip install ".[dev]"
- run: ruff check . && ruff format --check . && mypy pyfragment - run: ruff check . && ruff format --check . && mypy pyfragment --explicit-package-bases
test: test:
name: Tests (Python ${{ matrix.python-version }}) name: Tests (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
python-version: [ "3.10", "3.11", "3.12" ] # 3.13, 3.14 are not supported by some dependencies yet python-version: [ "3.10", "3.11", "3.12", "3.13", "3.14"]
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
@@ -41,6 +38,7 @@ jobs:
- uses: actions/setup-python@v6 - uses: actions/setup-python@v6
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
cache: pip
- name: Install package and dev dependencies - name: Install package and dev dependencies
run: pip install ".[dev]" run: pip install ".[dev]"
+6 -26
View File
@@ -2,17 +2,15 @@ name: Publish
on: on:
workflow_run: workflow_run:
workflows: [ "CI" ] workflows: ["CI"]
types: [ completed ] types: [completed]
branches: [ master ] branches: [master]
jobs: jobs:
version-check: version-check:
name: Version Check name: Version Check
if: github.event.workflow_run.conclusion == 'success' if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
outputs: outputs:
version: ${{ steps.version.outputs.value }} version: ${{ steps.version.outputs.value }}
is-new: ${{ steps.tag.outputs.is-new }} is-new: ${{ steps.tag.outputs.is-new }}
@@ -42,8 +40,6 @@ jobs:
needs: version-check needs: version-check
if: needs.version-check.outputs.is-new == 'true' if: needs.version-check.outputs.is-new == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
@@ -63,10 +59,8 @@ jobs:
publish: publish:
name: Publish to PyPI name: Publish to PyPI
needs: [ version-check, build ] needs: [version-check, build]
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
environment: environment:
name: pypi name: pypi
url: https://pypi.org/project/pyfragment/ url: https://pypi.org/project/pyfragment/
@@ -83,35 +77,21 @@ jobs:
release: release:
name: GitHub Release name: GitHub Release
needs: [ version-check, build ] needs: [version-check, build]
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
permissions: permissions:
contents: write contents: write
steps: steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/download-artifact@v8 - uses: actions/download-artifact@v8
with: with:
name: dist name: dist
path: dist path: dist
- name: Extract latest changelog entry
id: changelog
run: |
body=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
echo "body<<EOF" >> $GITHUB_OUTPUT
echo "$body" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- uses: softprops/action-gh-release@v3 - uses: softprops/action-gh-release@v3
with: with:
tag_name: v${{ needs.version-check.outputs.version }} tag_name: v${{ needs.version-check.outputs.version }}
name: v${{ needs.version-check.outputs.version }} name: v${{ needs.version-check.outputs.version }}
files: dist/* files: dist/*
body: ${{ steps.changelog.outputs.body }} generate_release_notes: true
make_latest: true make_latest: true
+28
View File
@@ -7,6 +7,34 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
--- ---
## [2026.3.1] — 2026-05-29
### Added
- Python 3.13 and 3.14 are now officially supported and included in the CI test matrix and PyPI classifiers.
- `WalletVersion` is now exported from the top-level `pyfragment` package.
### Changed
- `process_transaction` (internal) refactored into focused subfunctions: `_extract_message`, `_check_payment_balances`, `_broadcast_with_retry`.
- `raw_api_call()` moved from `FragmentClient` into `pyfragment.domains.base` and exposed as a standalone helper.
- `tonapi` domain internal helpers removed from public `__init__.py` exports; only `TonapiService` is exported.
- README rewritten with badges, structured sections, and complete usage examples.
- Added `CONTRIBUTING.md` and `SECURITY.md`.
### Fixed
- CI: `mypy` now runs with `--explicit-package-bases` to avoid false-positive import errors.
- CI: `pip` dependency cache enabled to speed up workflow runs.
- CI: `warn_unused_ignores` suppressed for `pyfragment.core.cookies` to handle the optional `rookiepy` dependency correctly across environments where the package may or may not be installed.
- Publish workflow now uses `generate_release_notes: true` instead of manual changelog extraction.
### Removed
- `tonapi/transfer.py` and associated `TonTransferResult` / `UsdtTransferResult` models (internal, unused).
---
## [2026.3.0] — 2026-05-21 ## [2026.3.0] — 2026-05-21
### Changed ### Changed
+56
View File
@@ -0,0 +1,56 @@
# Contributing to pyfragment
## Development setup
```bash
git clone https://github.com/bohd4nx/pyfragment.git
cd pyfragment
pip install -e ".[dev]"
```
## Running checks
```bash
# Lint and format
ruff check . --fix && ruff format .
# Type check
mypy . --explicit-package-bases
# Tests
pytest
```
All three must pass before opening a PR.
## Project structure
```
pyfragment/
client.py — FragmentClient (public entry point)
core/ — transport, cookies, constants
domains/ — one package per feature domain
ads/ — recharge_ads, topup_ton
anonymous_numbers/— buy_number, manage_number
giveaways/ — giveaway_stars, giveaway_premium
marketplace/ — search_usernames, search_numbers, search_gifts
purchases/ — purchase_stars, purchase_premium
tonapi/ — wallet info, transaction signing
models/ — result dataclasses and enums
exceptions.py — exception hierarchy
tests/ — unit tests (pytest)
examples/ — runnable usage examples (excluded from CI)
```
## Conventions
- All public async methods live on `FragmentClient` and delegate to a domain service.
- Domain functions receive a `FragmentClient` instance, never raw httpx clients.
- Patch targets in tests use the module where the name is **defined**, e.g. `pyfragment.domains.tonapi.transaction.process_transaction`.
- Versioning follows [CalVer](https://calver.org/): `YYYY.MINOR.MICRO`. Bump in `pyproject.toml`; tag as `vYYYY.MINOR.MICRO`.
## Pull requests
- Keep PRs focused — one feature or fix per PR.
- Update `CHANGELOG.md` under `[Unreleased]`.
- Add or update tests for any changed behaviour.
+98 -67
View File
@@ -1,23 +1,23 @@
<div align="center"> <div align="center">
<img src="https://www.bohd4n.dev/assets/projects/pyfragment.svg" alt="Fragment Logo" width="120" height="120" style="border-radius: 24px;"> <img src="https://www.bohd4n.dev/assets/projects/pyfragment.svg" alt="pyfragment" width="96" height="96" style="border-radius: 20px;"><br><br>
<h1 style="margin-top: 24px;">Fragment API</h1> # pyfragment
<p style="font-size: 18px; margin-bottom: 24px;"> [![PyPI](https://img.shields.io/pypi/v/pyfragment?style=flat&color=blue&label=PyPI)](https://pypi.org/project/pyfragment/)
<b>Async Python client for the Fragment API. Buy Stars and Premium, top up TON and Ads balances, run giveaways, manage anonymous numbers, and search Fragment listings.</b> [![Downloads](https://img.shields.io/pepy/dt/pyfragment?style=flat&color=blue&label=Downloads)](https://pepy.tech/projects/pyfragment)
</p> [![Python](https://img.shields.io/pypi/pyversions/pyfragment?style=flat&color=blue&label=Python)](https://python.org)
[![Tests](https://img.shields.io/github/actions/workflow/status/bohd4nx/pyfragment/ci.yml?style=flat&label=Tests&logo=github)](https://github.com/bohd4nx/pyfragment/actions)
[![License](https://img.shields.io/github/license/bohd4nx/pyfragment?style=flat&color=blue&label=License)](LICENSE)
[![PyPI version](https://img.shields.io/pypi/v/pyfragment?style=flat&color=blue)](https://pypi.org/project/pyfragment/) Async Python client for the **[Fragment](https://fragment.com)** marketplace API.
[![PyPI Downloads](https://static.pepy.tech/personalized-badge/pyfragment?period=total&units=INTERNATIONAL_SYSTEM&left_color=GREY&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/pyfragment) Buy Stars & Premium, run giveaways, top up TON and Ads balances,
[![Python](https://img.shields.io/badge/Python-3.10+-3776AB?style=flat&logo=python&logoColor=white)](https://python.org) manage anonymous numbers, and search Fragment listings.
[![Tests](https://img.shields.io/github/actions/workflow/status/bohd4nx/pyfragment/ci.yml?style=flat&label=tests&logo=github)](https://github.com/bohd4nx/pyfragment/actions)
[![License](https://img.shields.io/github/license/bohd4nx/pyfragment?style=flat&color=green)](https://github.com/bohd4nx/pyfragment/blob/master/LICENSE)
[Documentation](https://bohd4nx.gitbook.io/pyfragment/) · [Report Bug](https://github.com/bohd4nx/pyfragment/issues) · [Request Feature](https://github.com/bohd4nx/pyfragment/issues) · [**Donate TON**](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5) **[Documentation](https://bohd4nx.gitbook.io/pyfragment/)** · **[Examples](https://github.com/bohd4nx/pyfragment/tree/master/examples)** · **[Changelog](CHANGELOG.md)** · **[Donate TON](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5)**
</div> </div>
> **Disclaimer:** This project is not affiliated with, endorsed by, or in any way officially connected with [Fragment](https://fragment.com) or [Telegram](https://telegram.org). > **Disclaimer:** This project is not affiliated with [Fragment](https://fragment.com) or [Telegram](https://telegram.org).
--- ---
@@ -27,57 +27,14 @@
pip install pyfragment pip install pyfragment
``` ```
To install the latest unreleased changes from the `dev` branch:
```bash ```bash
# Latest dev build
pip install git+https://github.com/bohd4nx/pyfragment.git@dev pip install git+https://github.com/bohd4nx/pyfragment.git@dev
``` ```
Requires Python 3.10+.
--- ---
## Configuration ## Quick Start
| Parameter | Type | Default | Description |
| ---------------- | ------------- | -------- | -------------------------------------------------------- |
| `seed` | `str` | — | 24-word TON wallet mnemonic |
| `api_key` | `str` | — | Tonapi key from [tonconsole.com](https://tonconsole.com) |
| `cookies` | `dict \| str` | — | Fragment session cookies |
| `wallet_version` | `str` | `"V5R1"` | `"V4R2"` or `"V5R1"` |
| `timeout` | `float` | `30.0` | HTTP request timeout in seconds |
---
## Credentials
**Fragment cookies** — log in to [fragment.com](https://fragment.com) and connect your TON wallet. You can get cookies in two ways:
- **Automatically** (recommended) — install the optional browser extra and use `get_cookies_from_browser()`, which reads them directly from your browser's on-disk store. No extension needed:
```bash
pip install "pyfragment[browser]"
```
```python
from pyfragment import get_cookies_from_browser
result = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ...
# result.cookies — dict[str, str] to pass to FragmentClient
# result.expires — ISO 8601 expiry of stel_ssid, or None for session cookies
```
- **Manually** — install [Cookie Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm) and export these four keys: `stel_ssid`, `stel_dt`, `stel_token`, `stel_ton_token`. Pass them as a `dict` or JSON string.
Refresh when you get authentication errors.
**Tonapi key** — generate at [tonconsole.com](https://tonconsole.com).
**Seed phrase** — 24-word mnemonic from your TON wallet (Tonkeeper → Settings → Backup). Never share it.
---
## Usage
```python ```python
import asyncio import asyncio
@@ -96,34 +53,108 @@ async def main() -> None:
}, },
) as client: ) as client:
wallet = await client.get_wallet() wallet = await client.get_wallet()
print(f"Wallet: {wallet.address} | TON: {wallet.ton_balance} | USDT: {wallet.usdt_balance}") print(f"TON: {wallet.ton_balance} | USDT: {wallet.usdt_balance}")
recipient = "https://t.me/username" # also supports: @username, username recipient = "https://t.me/username" # also: @username, username
stars = await client.purchase_stars(recipient, amount=500, payment_method="usdt_ton") stars = await client.purchase_stars(recipient, amount=500, payment_method="usdt_ton")
print(f"Stars sent: {stars.amount} to {stars.username} | tx: {stars.transaction_id}") print(f"Sent {stars.amount} Stars to {stars.username} | tx: {stars.transaction_id}")
premium = await client.purchase_premium(recipient, months=6, payment_method="ton") premium = await client.purchase_premium(recipient, months=6, payment_method="ton")
print(f"Premium sent: {premium.amount} months to {premium.username} | tx: {premium.transaction_id}") print(f"Sent Premium {premium.amount}m to {premium.username} | tx: {premium.transaction_id}")
asyncio.run(main()) asyncio.run(main())
``` ```
Full runnable examples: ---
- https://github.com/bohd4nx/pyfragment/tree/master/examples ## Configuration
Payload debug/decode helper (thanks): | Parameter | Type | Default | Description |
| ---------------- | ------------- | -------- | ----------------------------------------------------------- |
| `seed` | `str` | — | 12- or 24-word TON wallet mnemonic |
| `api_key` | `str` | — | Tonapi key from [tonconsole.com](https://tonconsole.com) |
| `cookies` | `dict \| str` | — | Fragment session cookies |
| `wallet_version` | `str` | `"V5R1"` | `"V4R2"` or `"V5R1"` — also accepts `WalletVersion` literal |
| `timeout` | `float` | `30.0` | HTTP request timeout in seconds |
- https://ton-cell-abi-viewer.vercel.app/ ---
## Credentials
### Fragment cookies
Log in to [fragment.com](https://fragment.com) and connect your TON wallet.
**Automatically** (recommended) — reads directly from your browser, no extension needed:
```bash
pip install "pyfragment[browser]"
```
```python
from pyfragment import get_cookies_from_browser
result = get_cookies_from_browser("chrome") # firefox, edge, brave, ...
# result.cookies — dict[str, str] ready to pass to FragmentClient
# result.expires — ISO 8601 expiry of stel_ssid, or None for session cookies
```
**Manually** — use [Cookie Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm) and export: `stel_ssid`, `stel_dt`, `stel_token`, `stel_ton_token`. Pass as a `dict` or JSON string.
Refresh cookies when you get authentication errors.
### Tonapi key
Generate at [tonconsole.com](https://tonconsole.com).
### Seed phrase
12- or 24-word mnemonic from your TON wallet (**Tonkeeper → Settings → Backup**). Never share it.
---
## Logging
`pyfragment` uses the standard `logging` module under the `pyfragment` namespace and is silent by default:
```python
import logging
logging.basicConfig(level=logging.INFO)
logging.getLogger("pyfragment").setLevel(logging.DEBUG) # for detailed request logs
```
---
## Error Handling
All exceptions inherit from `FragmentError`:
```python
from pyfragment import (
ConfigurationError, # invalid arguments (amount, months, payment_method…)
UserNotFoundError, # recipient not found on Fragment
WalletError, # insufficient TON or USDT balance
TransactionError, # broadcast failed, duplicate seqno, invalid payload
FragmentAPIError, # Fragment API returned an error response
FragmentPageError, # page fetch or hash extraction failed
AnonymousNumberError, # number not owned, wrong state, login code issues
CookieError, # missing or malformed session cookies
ParseError, # failed to decode Fragment payload
VerificationError, # on-chain verification step failed
OperationError, # generic operation-level failure
UnexpectedError, # unexpected API response structure
)
```
--- ---
<div align="center"> <div align="center">
### Made with ❤️ by [@bohd4nx](https://t.me/bohd4nx) Made with ❤️ by [@bohd4nx](https://t.me/bohd4nx) · [Contributing](CONTRIBUTING.md) · [Security](SECURITY.md)
**Star ⭐ this repo if you found it useful!** **Star ⭐ if you found it useful**
</div> </div>
+19
View File
@@ -0,0 +1,19 @@
# Security Policy
## Reporting a vulnerability
Please **do not** open a public GitHub issue for security vulnerabilities.
Report them privately via GitHub's [Security Advisory](https://github.com/bohd4nx/pyfragment/security/advisories/new) feature, or contact the maintainer directly at [@bohd4nx](https://t.me/bohd4nx) on Telegram.
Include:
- A description of the vulnerability and its potential impact.
- Steps to reproduce or a proof-of-concept.
- Affected versions.
You will receive a response within 72 hours. Once the fix is released, the advisory will be published.
## Scope
This library handles sensitive credentials (TON seed phrases, Fragment session cookies, Tonapi keys). Please treat any finding that could expose or misuse these credentials as high severity.
+5 -1
View File
@@ -1,3 +1,4 @@
import logging
from importlib.metadata import version from importlib.metadata import version
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
@@ -20,12 +21,14 @@ from pyfragment.exceptions import (
) )
from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult
from pyfragment.models.cookies import CookieResult from pyfragment.models.cookies import CookieResult
from pyfragment.models.enums import PaymentMethod from pyfragment.models.enums import PaymentMethod, WalletVersion
from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult
from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult
from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult, PremiumResult, StarsResult from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult, PremiumResult, StarsResult
from pyfragment.models.wallet import WalletInfo from pyfragment.models.wallet import WalletInfo
logging.getLogger("pyfragment").addHandler(logging.NullHandler())
__version__: str = version("pyfragment") __version__: str = version("pyfragment")
__all__ = [ __all__ = [
@@ -62,5 +65,6 @@ __all__ = [
"UnexpectedError", "UnexpectedError",
# literal types # literal types
"PaymentMethod", "PaymentMethod",
"WalletVersion",
"get_cookies_from_browser", "get_cookies_from_browser",
] ]
+36 -26
View File
@@ -3,12 +3,10 @@ from __future__ import annotations
import json import json
from typing import Any, cast, get_args from typing import Any, cast, get_args
import httpx from pyfragment.core.constants import DEFAULT_TIMEOUT, FRAGMENT_BASE_URL, REQUIRED_COOKIE_KEYS
from pyfragment.core.constants import BASE_HEADERS, DEFAULT_TIMEOUT, FRAGMENT_BASE_URL, REQUIRED_COOKIE_KEYS
from pyfragment.core.transport import fragment_request, get_fragment_hash
from pyfragment.domains.ads.service import AdsService from pyfragment.domains.ads.service import AdsService
from pyfragment.domains.anonymous_numbers.service import AnonymousNumbersService from pyfragment.domains.anonymous_numbers.service import AnonymousNumbersService
from pyfragment.domains.base import raw_api_call
from pyfragment.domains.giveaways.service import GiveawaysService from pyfragment.domains.giveaways.service import GiveawaysService
from pyfragment.domains.marketplace.service import MarketplaceService from pyfragment.domains.marketplace.service import MarketplaceService
from pyfragment.domains.purchases.service import PurchasesService from pyfragment.domains.purchases.service import PurchasesService
@@ -31,7 +29,7 @@ class FragmentClient:
connected with Fragment or Telegram. connected with Fragment or Telegram.
Args: Args:
seed: 24-word mnemonic phrase for the TON wallet. seed: 12- or 24-word mnemonic phrase for the TON wallet.
api_key: Tonapi API key — get one at https://tonconsole.com. api_key: Tonapi API key — get one at https://tonconsole.com.
cookies: Fragment session cookies as a dict or JSON string. cookies: Fragment session cookies as a dict or JSON string.
wallet_version: Wallet contract version — ``"V4R2"`` or ``"V5R1"`` (default). wallet_version: Wallet contract version — ``"V4R2"`` or ``"V5R1"`` (default).
@@ -53,14 +51,17 @@ class FragmentClient:
print(result.transaction_id) print(result.transaction_id)
""" """
def __init__( @staticmethod
self, def _parse_cookies(cookies: dict[str, Any] | str) -> dict[str, Any]:
seed: str, if isinstance(cookies, str):
api_key: str, try:
cookies: dict[str, Any] | str, cookies = json.loads(cookies)
wallet_version: str = "V5R1", except Exception as exc:
timeout: float = DEFAULT_TIMEOUT, raise CookieError(CookieError.READ_FAILED.format(exc=exc)) from exc
) -> None: return cast(dict[str, Any], cookies)
@staticmethod
def _validate_required(seed: str, api_key: str) -> None:
missing = [name for name, val in (("seed", seed), ("api_key", api_key)) if not val or not str(val).strip()] missing = [name for name, val in (("seed", seed), ("api_key", api_key)) if not val or not str(val).strip()]
if missing: if missing:
raise ConfigurationError(ConfigurationError.MISSING_VARS.format(keys=", ".join(missing))) raise ConfigurationError(ConfigurationError.MISSING_VARS.format(keys=", ".join(missing)))
@@ -72,16 +73,14 @@ class FragmentClient:
if len(api_key.strip()) < 68: if len(api_key.strip()) < 68:
raise ConfigurationError(ConfigurationError.INVALID_API_KEY.format(length=len(api_key.strip()))) raise ConfigurationError(ConfigurationError.INVALID_API_KEY.format(length=len(api_key.strip())))
if isinstance(cookies, str): @staticmethod
try: def _validate_cookie_keys(cookies: dict[str, Any]) -> None:
cookies = json.loads(cookies) missing_keys = [k for k in REQUIRED_COOKIE_KEYS if not str(cookies.get(k, "")).strip()]
except Exception as exc:
raise CookieError(CookieError.READ_FAILED.format(exc=exc)) from exc
missing_keys = [k for k in REQUIRED_COOKIE_KEYS if not str(cast(dict[str, Any], cookies).get(k, "")).strip()]
if missing_keys: if missing_keys:
raise CookieError(CookieError.MISSING_KEYS.format(keys=", ".join(missing_keys))) raise CookieError(CookieError.MISSING_KEYS.format(keys=", ".join(missing_keys)))
@staticmethod
def _normalize_wallet_version(wallet_version: str) -> WalletVersion:
version = wallet_version.strip().upper() version = wallet_version.strip().upper()
if version not in get_args(WalletVersion): if version not in get_args(WalletVersion):
raise ConfigurationError( raise ConfigurationError(
@@ -89,11 +88,25 @@ class FragmentClient:
version=version, supported=", ".join(sorted(get_args(WalletVersion))) version=version, supported=", ".join(sorted(get_args(WalletVersion)))
) )
) )
return cast(WalletVersion, version)
def __init__(
self,
seed: str,
api_key: str,
cookies: dict[str, Any] | str,
wallet_version: str = "V5R1",
timeout: float = DEFAULT_TIMEOUT,
) -> None:
self._validate_required(seed, api_key)
parsed_cookies = self._parse_cookies(cookies)
self._validate_cookie_keys(parsed_cookies)
version = self._normalize_wallet_version(wallet_version)
self.seed: str = seed.strip() self.seed: str = seed.strip()
self.api_key: str = api_key.strip() self.api_key: str = api_key.strip()
self.cookies: dict[str, Any] = cast(dict[str, Any], cookies) self.cookies: dict[str, Any] = parsed_cookies
self.wallet_version: WalletVersion = version # type: ignore[assignment] self.wallet_version: WalletVersion = version
self.timeout: float = timeout self.timeout: float = timeout
self.marketplace = MarketplaceService(self) self.marketplace = MarketplaceService(self)
self.purchases = PurchasesService(self) self.purchases = PurchasesService(self)
@@ -371,7 +384,4 @@ class FragmentClient:
page_url="https://fragment.com/premium/gift", page_url="https://fragment.com/premium/gift",
) )
""" """
headers = {**BASE_HEADERS, "referer": page_url, "x-aj-referer": page_url} return await raw_api_call(self.cookies, self.timeout, method, data, page_url)
async with httpx.AsyncClient(cookies=self.cookies, timeout=self.timeout) as session:
fragment_hash = await get_fragment_hash(self.cookies, headers, page_url, self.timeout)
return await fragment_request(session, fragment_hash, headers, {"method": method, **(data or {})})
+3 -3
View File
@@ -9,9 +9,9 @@ from pyfragment.exceptions import CookieError
from pyfragment.models.cookies import CookieResult from pyfragment.models.cookies import CookieResult
try: try:
import rookiepy # type: ignore[import-not-found] import rookiepy
except Exception: except Exception: # noqa: BLE001
rookiepy = None rookiepy = None # type: ignore[assignment]
def get_cookies_from_browser(browser: str = "chrome") -> CookieResult: def get_cookies_from_browser(browser: str = "chrome") -> CookieResult:
+7 -1
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE
@@ -13,6 +14,9 @@ if TYPE_CHECKING:
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
logger = logging.getLogger(__name__)
async def recharge_ads(client: FragmentClient, account: str, amount: int) -> AdsRechargeResult: async def recharge_ads(client: FragmentClient, account: str, amount: int) -> AdsRechargeResult:
if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000): if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000):
raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT) raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT)
@@ -42,7 +46,9 @@ async def recharge_ads(client: FragmentClient, account: str, amount: int) -> Ads
tx_hash = await process_transaction(client, transaction) tx_hash = await process_transaction(client, transaction)
return AdsRechargeResult(transaction_id=tx_hash, amount=amount) return AdsRechargeResult(transaction_id=tx_hash, amount=amount)
except FragmentError: except FragmentError as exc:
logger.error("Failed to recharge Ads account '%s' for %s TON: %s", account, amount, exc, exc_info=True)
raise raise
except Exception as exc: except Exception as exc:
logger.exception("Failed to recharge Ads account '%s' for %s TON due to an unexpected error", account, amount)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
+7 -1
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE
@@ -21,6 +22,9 @@ if TYPE_CHECKING:
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
logger = logging.getLogger(__name__)
async def topup_ton(client: FragmentClient, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult: async def topup_ton(client: FragmentClient, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000): if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000):
raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT) raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT)
@@ -57,7 +61,9 @@ async def topup_ton(client: FragmentClient, username: str, amount: int, show_sen
tx_hash = await process_transaction(client, transaction, required_payment_amount=required_payment_amount) tx_hash = await process_transaction(client, transaction, required_payment_amount=required_payment_amount)
return AdsTopupResult(transaction_id=tx_hash, username=username, amount=amount) return AdsTopupResult(transaction_id=tx_hash, username=username, amount=amount)
except FragmentError: except FragmentError as exc:
logger.error("Failed to top up TON for user '%s' with %s TON: %s", username, amount, exc, exc_info=True)
raise raise
except Exception as exc: except Exception as exc:
logger.exception("Failed to top up TON for user '%s' with %s TON due to an unexpected error", username, amount)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
+23 -3
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import html import html
import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from pyfragment.core.constants import NUMBERS_PAGE from pyfragment.core.constants import NUMBERS_PAGE
@@ -12,6 +13,9 @@ if TYPE_CHECKING:
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
logger = logging.getLogger(__name__)
def _strip_plus(number: str) -> str: def _strip_plus(number: str) -> str:
return number.lstrip("+") if isinstance(number, str) else number return number.lstrip("+") if isinstance(number, str) else number
@@ -32,9 +36,11 @@ async def get_login_code(client: FragmentClient, number: str) -> LoginCodeResult
return LoginCodeResult(number=number, code=code, active_sessions=active_sessions) return LoginCodeResult(number=number, code=code, active_sessions=active_sessions)
except FragmentError: except FragmentError as exc:
logger.error("Failed to get login code for number '%s': %s", number, exc, exc_info=True)
raise raise
except Exception as exc: except Exception as exc:
logger.exception("Failed to get login code for number '%s' due to an unexpected error", number)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
@@ -50,9 +56,21 @@ async def toggle_login_codes(client: FragmentClient, number: str, can_receive: b
if result.get("error"): if result.get("error"):
raise FragmentAPIError(html.unescape(result["error"])) raise FragmentAPIError(html.unescape(result["error"]))
except FragmentError: except FragmentError as exc:
logger.error(
"Failed to toggle login code delivery for number '%s' (can_receive=%s): %s",
number,
can_receive,
exc,
exc_info=True,
)
raise raise
except Exception as exc: except Exception as exc:
logger.exception(
"Failed to toggle login code delivery for number '%s' (can_receive=%s) due to an unexpected error",
number,
can_receive,
)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
@@ -88,7 +106,9 @@ async def terminate_sessions(client: FragmentClient, number: str) -> TerminateSe
return TerminateSessionsResult(number=number, message=result.get("msg")) return TerminateSessionsResult(number=number, message=result.get("msg"))
except FragmentError: except FragmentError as exc:
logger.error("Failed to terminate sessions for number '%s': %s", number, exc, exc_info=True)
raise raise
except Exception as exc: except Exception as exc:
logger.exception("Failed to terminate sessions for number '%s' due to an unexpected error", number)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
+30 -1
View File
@@ -1,10 +1,39 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING import logging
from typing import TYPE_CHECKING, Any
import httpx
from pyfragment.core.constants import BASE_HEADERS
from pyfragment.core.transport import fragment_request, get_fragment_hash
if TYPE_CHECKING: if TYPE_CHECKING:
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
logger = logging.getLogger(__name__)
async def raw_api_call(
cookies: dict[str, Any],
timeout: float,
method: str,
data: dict[str, Any] | None,
page_url: str,
) -> dict[str, Any]:
payload = {"method": method, **(data or {})}
headers = {**BASE_HEADERS, "referer": page_url, "x-aj-referer": page_url}
logger.debug("Starting Fragment API call '%s' on %s", method, page_url)
try:
async with httpx.AsyncClient(cookies=cookies, timeout=timeout) as session:
fragment_hash = await get_fragment_hash(cookies, headers, page_url, timeout)
response = await fragment_request(session, fragment_hash, headers, payload)
logger.debug("Completed Fragment API call '%s' with response keys: %s", method, sorted(response.keys()))
return response
except Exception:
logger.exception("Failed to call Fragment API method '%s' on %s", method, page_url)
raise
class BaseService: class BaseService:
def __init__(self, client: FragmentClient) -> None: def __init__(self, client: FragmentClient) -> None:
+56 -2
View File
@@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
import json import json
import logging
import random
from typing import TYPE_CHECKING, get_args from typing import TYPE_CHECKING, get_args
from pyfragment.core.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE, STARS_GIVEAWAY_PAGE from pyfragment.core.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE, STARS_GIVEAWAY_PAGE
@@ -22,6 +24,9 @@ if TYPE_CHECKING:
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
logger = logging.getLogger(__name__)
async def giveaway_stars( async def giveaway_stars(
client: FragmentClient, client: FragmentClient,
channel: str, channel: str,
@@ -47,6 +52,12 @@ async def giveaway_stars(
if not recipient: if not recipient:
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel)) raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel))
await client.call(
"updateStarsGiveawayState",
{"mode": "new", "lv": "false", "dh": str(random.randint(100_000_000, 999_999_999))},
page_url=STARS_GIVEAWAY_PAGE,
)
result = await client.call( result = await client.call(
"initGiveawayStarsRequest", "initGiveawayStarsRequest",
{ {
@@ -84,9 +95,25 @@ async def giveaway_stars(
) )
return StarsGiveawayResult(transaction_id=tx_hash, channel=channel, winners=winners, amount=amount) return StarsGiveawayResult(transaction_id=tx_hash, channel=channel, winners=winners, amount=amount)
except FragmentError: except FragmentError as exc:
logger.error(
"Failed to run Stars giveaway for channel '%s' (winners=%s, amount=%s, payment_method='%s'): %s",
channel,
winners,
amount,
payment_method,
exc,
exc_info=True,
)
raise raise
except Exception as exc: except Exception as exc:
logger.exception(
"Failed to run Stars giveaway for channel '%s' (winners=%s, amount=%s, payment_method='%s') due to an unexpected error",
channel,
winners,
amount,
payment_method,
)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
@@ -119,6 +146,17 @@ async def giveaway_premium(
if not recipient: if not recipient:
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel)) raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel))
await client.call(
"updatePremiumGiveawayState",
{
"mode": "new",
"lv": "false",
"dh": str(random.randint(100_000_000, 999_999_999)),
"quantity": "",
},
page_url=PREMIUM_GIVEAWAY_PAGE,
)
result = await client.call( result = await client.call(
"initGiveawayPremiumRequest", "initGiveawayPremiumRequest",
{ {
@@ -156,7 +194,23 @@ async def giveaway_premium(
) )
return PremiumGiveawayResult(transaction_id=tx_hash, channel=channel, winners=winners, amount=months) return PremiumGiveawayResult(transaction_id=tx_hash, channel=channel, winners=winners, amount=months)
except FragmentError: except FragmentError as exc:
logger.error(
"Failed to run Premium giveaway for channel '%s' (winners=%s, months=%s, payment_method='%s'): %s",
channel,
winners,
months,
payment_method,
exc,
exc_info=True,
)
raise raise
except Exception as exc: except Exception as exc:
logger.exception(
"Failed to run Premium giveaway for channel '%s' (winners=%s, months=%s, payment_method='%s') due to an unexpected error",
channel,
winners,
months,
payment_method,
)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
+39 -3
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from pyfragment.core.constants import FRAGMENT_BASE_URL, GIFTS_PAGE, NUMBERS_PAGE from pyfragment.core.constants import FRAGMENT_BASE_URL, GIFTS_PAGE, NUMBERS_PAGE
@@ -11,6 +12,9 @@ if TYPE_CHECKING:
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
logger = logging.getLogger(__name__)
async def search_usernames( async def search_usernames(
client: FragmentClient, client: FragmentClient,
query: str = "", query: str = "",
@@ -36,9 +40,19 @@ async def search_usernames(
next_offset_id = str(raw_noi) if raw_noi else None next_offset_id = str(raw_noi) if raw_noi else None
return UsernamesResult(items=items, next_offset_id=next_offset_id) return UsernamesResult(items=items, next_offset_id=next_offset_id)
except FragmentError: except FragmentError as exc:
logger.error(
"Failed to search usernames (query='%s', sort='%s', filter='%s', offset_id='%s'): %s",
query,
sort,
filter,
offset_id,
exc,
exc_info=True,
)
raise raise
except Exception as exc: except Exception as exc:
logger.exception("Failed to search usernames for query '%s' due to an unexpected error", query)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
@@ -67,9 +81,19 @@ async def search_numbers(
next_offset_id = str(raw_noi) if raw_noi else None next_offset_id = str(raw_noi) if raw_noi else None
return NumbersResult(items=items, next_offset_id=next_offset_id) return NumbersResult(items=items, next_offset_id=next_offset_id)
except FragmentError: except FragmentError as exc:
logger.error(
"Failed to search numbers (query='%s', sort='%s', filter='%s', offset_id='%s'): %s",
query,
sort,
filter,
offset_id,
exc,
exc_info=True,
)
raise raise
except Exception as exc: except Exception as exc:
logger.exception("Failed to search numbers for query '%s' due to an unexpected error", query)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
@@ -106,7 +130,19 @@ async def search_gifts(
items, next_offset = parse_gift_items(result.get("html") or "") items, next_offset = parse_gift_items(result.get("html") or "")
return GiftsResult(items=items, next_offset=next_offset) return GiftsResult(items=items, next_offset=next_offset)
except FragmentError: except FragmentError as exc:
logger.error(
"Failed to search gifts (query='%s', collection='%s', sort='%s', filter='%s', view='%s', offset='%s'): %s",
query,
collection,
sort,
filter,
view,
offset,
exc,
exc_info=True,
)
raise raise
except Exception as exc: except Exception as exc:
logger.exception("Failed to search gifts for query '%s' due to an unexpected error", query)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
+34 -2
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import logging
import time import time
from typing import TYPE_CHECKING, get_args from typing import TYPE_CHECKING, get_args
@@ -23,6 +24,9 @@ if TYPE_CHECKING:
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
logger = logging.getLogger(__name__)
async def purchase_stars( async def purchase_stars(
client: FragmentClient, client: FragmentClient,
username: str, username: str,
@@ -84,9 +88,23 @@ async def purchase_stars(
) )
return StarsResult(transaction_id=tx_hash, username=username, amount=amount) return StarsResult(transaction_id=tx_hash, username=username, amount=amount)
except FragmentError: except FragmentError as exc:
logger.error(
"Failed to purchase %s Stars for user '%s' using '%s': %s",
amount,
username,
payment_method,
exc,
exc_info=True,
)
raise raise
except Exception as exc: except Exception as exc:
logger.exception(
"Failed to purchase %s Stars for user '%s' using '%s' due to an unexpected error",
amount,
username,
payment_method,
)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
@@ -151,7 +169,21 @@ async def purchase_premium(
) )
return PremiumResult(transaction_id=tx_hash, username=username, amount=months) return PremiumResult(transaction_id=tx_hash, username=username, amount=months)
except FragmentError: except FragmentError as exc:
logger.error(
"Failed to purchase %s months of Premium for user '%s' using '%s': %s",
months,
username,
payment_method,
exc,
exc_info=True,
)
raise raise
except Exception as exc: except Exception as exc:
logger.exception(
"Failed to purchase %s months of Premium for user '%s' using '%s' due to an unexpected error",
months,
username,
payment_method,
)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
-18
View File
@@ -1,23 +1,5 @@
from pyfragment.domains.tonapi.account import (
check_ton_payment_balance,
check_usdt_payment_balance,
get_account_info,
get_usdt_balance,
get_wallet_info,
)
from pyfragment.domains.tonapi.service import TonapiService from pyfragment.domains.tonapi.service import TonapiService
from pyfragment.domains.tonapi.transaction import clean_decode, process_transaction
from pyfragment.domains.tonapi.transfer import send_ton_transfer, send_usdt_transfer
__all__ = [ __all__ = [
"TonapiService", "TonapiService",
"clean_decode",
"check_ton_payment_balance",
"check_usdt_payment_balance",
"get_account_info",
"get_usdt_balance",
"get_wallet_info",
"process_transaction",
"send_ton_transfer",
"send_usdt_transfer",
] ]
+25
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import base64 import base64
import logging
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from ton_core import NetworkGlobalID from ton_core import NetworkGlobalID
@@ -16,6 +17,9 @@ if TYPE_CHECKING:
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
logger = logging.getLogger(__name__)
async def get_usdt_balance(ton: Any, wallet_address: str) -> float: async def get_usdt_balance(ton: Any, wallet_address: str) -> float:
"""Return the USDT balance for a Fragment-linked TON wallet.""" """Return the USDT balance for a Fragment-linked TON wallet."""
try: try:
@@ -29,9 +33,12 @@ async def get_usdt_balance(ton: Any, wallet_address: str) -> float:
return float(raw_balance) / 1_000_000.0 return float(raw_balance) / 1_000_000.0
except ProviderResponseError as exc: except ProviderResponseError as exc:
if exc.code == 404: if exc.code == 404:
logger.debug("No USDT jetton wallet found for '%s'; treating balance as 0", wallet_address)
return 0.0 return 0.0
logger.error("Failed to load USDT balance for wallet '%s': %s", wallet_address, exc, exc_info=True)
raise WalletError(WalletError.USDT_BALANCE_CHECK_FAILED.format(exc=exc)) from exc raise WalletError(WalletError.USDT_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
except Exception as exc: except Exception as exc:
logger.exception("Failed to load USDT balance for wallet '%s' due to an unexpected error", wallet_address)
raise WalletError(WalletError.USDT_BALANCE_CHECK_FAILED.format(exc=exc)) from exc raise WalletError(WalletError.USDT_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
@@ -47,6 +54,11 @@ async def check_ton_payment_balance(
required_ton = max(tx_price_ton, MIN_TON_BALANCE) required_ton = max(tx_price_ton, MIN_TON_BALANCE)
if balance_ton < required_ton: if balance_ton < required_ton:
logger.error(
"Failed TON balance check: balance=%s TON, required=%s TON",
round(balance_ton, 6),
round(required_ton, 6),
)
raise WalletError(WalletError.LOW_TON_BALANCE.format(balance=balance_ton, required=required_ton)) raise WalletError(WalletError.LOW_TON_BALANCE.format(balance=balance_ton, required=required_ton))
@@ -58,11 +70,22 @@ async def check_usdt_payment_balance(
) -> None: ) -> None:
"""Validate that the wallet can cover a USDT-denominated payment.""" """Validate that the wallet can cover a USDT-denominated payment."""
if balance_ton < MIN_TON_BALANCE: if balance_ton < MIN_TON_BALANCE:
logger.error(
"Failed TON gas reserve check for USDT payment: balance=%s TON, required=%s TON",
round(balance_ton, 6),
MIN_TON_BALANCE,
)
raise WalletError(WalletError.LOW_TON_BALANCE.format(balance=balance_ton, required=MIN_TON_BALANCE)) raise WalletError(WalletError.LOW_TON_BALANCE.format(balance=balance_ton, required=MIN_TON_BALANCE))
usdt_balance = await get_usdt_balance(ton, wallet_address) usdt_balance = await get_usdt_balance(ton, wallet_address)
required_usdt = required_payment_amount if required_payment_amount is not None else MIN_USDT_BALANCE required_usdt = required_payment_amount if required_payment_amount is not None else MIN_USDT_BALANCE
if usdt_balance < required_usdt: if usdt_balance < required_usdt:
logger.error(
"Failed USDT balance check for wallet '%s': balance=%s USDT, required=%s USDT",
wallet_address,
round(usdt_balance, 6),
round(required_usdt, 6),
)
raise WalletError(WalletError.LOW_USDT_BALANCE.format(balance=usdt_balance, required=required_usdt)) raise WalletError(WalletError.LOW_USDT_BALANCE.format(balance=usdt_balance, required=required_usdt))
@@ -80,6 +103,7 @@ async def get_account_info(client: FragmentClient) -> dict[str, Any]:
"walletStateInit": base64.b64encode(boc).decode(), "walletStateInit": base64.b64encode(boc).decode(),
} }
except Exception as exc: except Exception as exc:
logger.exception("Failed to build Fragment account info from the configured wallet")
raise WalletError(WalletError.ACCOUNT_INFO_FAILED.format(exc=exc)) from exc raise WalletError(WalletError.ACCOUNT_INFO_FAILED.format(exc=exc)) from exc
@@ -99,4 +123,5 @@ async def get_wallet_info(client: FragmentClient) -> WalletInfo:
usdt_balance=round(usdt_balance, 4), usdt_balance=round(usdt_balance, 4),
) )
except Exception as exc: except Exception as exc:
logger.exception("Failed to fetch wallet info from Tonapi")
raise WalletError(WalletError.WALLET_INFO_FAILED.format(exc=exc)) from exc raise WalletError(WalletError.WALLET_INFO_FAILED.format(exc=exc)) from exc
+88 -42
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio import asyncio
import base64 import base64
import logging
import random import random
import ssl import ssl
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
@@ -19,6 +20,9 @@ if TYPE_CHECKING:
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
logger = logging.getLogger(__name__)
def clean_decode(payload: str) -> str | Cell: def clean_decode(payload: str) -> str | Cell:
"""Decode a base64 BOC comment from Fragment into text when possible. """Decode a base64 BOC comment from Fragment into text when possible.
@@ -43,9 +47,81 @@ def clean_decode(payload: str) -> str | Cell:
except UnicodeDecodeError: except UnicodeDecodeError:
return cell return cell
except Exception as exc: except Exception as exc:
logger.exception("Failed to decode Fragment payload")
raise ParseError(ParseError.UNPARSEABLE.format(context="payload decode", exc=exc)) from exc raise ParseError(ParseError.UNPARSEABLE.format(context="payload decode", exc=exc)) from exc
def _extract_message(transaction_data: dict[str, Any]) -> dict[str, Any]:
"""Validate and extract the first message from a Fragment transaction payload."""
if "transaction" not in transaction_data or not transaction_data["transaction"].get("messages"):
logger.error("Failed to process transaction: missing transaction payload or messages")
raise TransactionError(TransactionError.INVALID_PAYLOAD)
result: dict[str, Any] = transaction_data["transaction"]["messages"][0]
return result
async def _check_payment_balances(
wallet: Any,
payment_method: PaymentMethod,
amount_ton: float,
required_payment_amount: float | None,
transaction_data: dict[str, Any],
ton: Any,
) -> None:
"""Refresh wallet and verify sufficient balance before broadcasting."""
try:
await wallet.refresh()
balance_ton = wallet.balance / 1_000_000_000
if payment_method == "ton":
await check_ton_payment_balance(balance_ton, amount_ton, required_payment_amount)
else:
# USDT is paid from the Fragment-linked wallet, not the signing wallet.
fragment_wallet_address = transaction_data["transaction"].get("from", "")
await check_usdt_payment_balance(balance_ton, required_payment_amount, ton, fragment_wallet_address)
except WalletError:
raise
except Exception as exc:
logger.exception("Failed to validate balances before broadcasting transaction")
raise WalletError(WalletError.TON_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
async def _broadcast_with_retry(wallet: Any, message: dict[str, Any], payload: str | Cell) -> str:
"""Attempt to broadcast a transaction up to 3 times, handling rate-limit and seqno errors."""
for attempt in range(3):
try:
result = await wallet.transfer(
destination=message["address"],
amount=int(message["amount"]), # nanotons, not TON
body=payload,
)
return str(result.normalized_hash)
except ProviderResponseError as exc:
if exc.code == 429 and attempt == 0:
logger.warning(
"Broadcast rate-limited (429), retrying transaction once: %s",
exc,
exc_info=True,
)
await asyncio.sleep(1 + random.uniform(0, 0.5))
continue
if exc.code == 406 and "seqno" in str(exc).lower():
if attempt < 2:
logger.warning(
"Broadcast seqno conflict (406), retrying attempt %s: %s",
attempt + 2,
exc,
exc_info=True,
)
await asyncio.sleep(2 + random.uniform(0, 1))
continue
logger.error("Failed to broadcast transaction after seqno retries")
raise TransactionError(TransactionError.DUPLICATE_SEQNO) from exc
raise
logger.error("Failed to broadcast transaction: transfer loop exited without result")
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc="transfer loop exited without result"))
async def process_transaction( async def process_transaction(
client: FragmentClient, client: FragmentClient,
transaction_data: dict[str, Any], transaction_data: dict[str, Any],
@@ -63,62 +139,32 @@ async def process_transaction(
Returns: Returns:
Normalized transaction hash string. Normalized transaction hash string.
""" """
if "transaction" not in transaction_data or not transaction_data["transaction"].get("messages"): message = _extract_message(transaction_data)
raise TransactionError(TransactionError.INVALID_PAYLOAD)
message = transaction_data["transaction"]["messages"][0]
amount_ton = int(message["amount"]) / 1_000_000_000 amount_ton = int(message["amount"]) / 1_000_000_000
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton: async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
wallet_cls = WALLET_CLASSES[client.wallet_version] wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed) wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
# Check balance first so we fail before trying to broadcast on-chain. await _check_payment_balances(wallet, payment_method, amount_ton, required_payment_amount, transaction_data, ton)
try:
await wallet.refresh() payload = clean_decode(str(message.get("payload", "")))
balance_ton = wallet.balance / 1_000_000_000
if payment_method == "ton":
wallet.address.to_str(False, False)
await check_ton_payment_balance(balance_ton, amount_ton, required_payment_amount)
else:
# USDT is paid from the Fragment-linked wallet, not the signing wallet.
fragment_wallet_address = transaction_data["transaction"].get("from", "")
await check_usdt_payment_balance(balance_ton, required_payment_amount, ton, fragment_wallet_address)
except WalletError:
raise
except Exception as exc:
raise WalletError(WalletError.TON_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
try: try:
raw_payload = str(message.get("payload", "")) return await _broadcast_with_retry(wallet, message, payload)
payload = clean_decode(raw_payload)
for attempt in range(3):
try:
result = await wallet.transfer(
destination=message["address"],
amount=int(message["amount"]), # nanotons, not TON
body=payload,
)
return str(result.normalized_hash)
except ProviderResponseError as exc:
if exc.code == 429 and attempt == 0:
await asyncio.sleep(1 + random.uniform(0, 0.5))
continue
if exc.code == 406 and "seqno" in str(exc).lower():
if attempt < 2:
await asyncio.sleep(2 + random.uniform(0, 1))
continue
raise TransactionError(TransactionError.DUPLICATE_SEQNO) from exc
raise
except (WalletError, TransactionError): except (WalletError, TransactionError):
raise raise
except Exception as exc: except Exception as exc:
cause: BaseException | None = exc cause: BaseException | None = exc
while cause is not None: while cause is not None:
if isinstance(cause, ssl.SSLError): if isinstance(cause, ssl.SSLError):
logger.exception("Failed to broadcast transaction due to SSL error")
raise TransactionError(TransactionError.BROADCAST_FAILED_SSL.format(exc=exc)) from exc raise TransactionError(TransactionError.BROADCAST_FAILED_SSL.format(exc=exc)) from exc
cause = cause.__cause__ or cause.__context__ cause = cause.__cause__ or cause.__context__
logger.exception(
"Failed to broadcast transaction to '%s' for %s nanotons using payment method '%s'",
message["address"],
message["amount"],
payment_method,
)
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc=exc)) from exc raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc=exc)) from exc
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc="transfer loop exited without result"))
-91
View File
@@ -1,91 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from ton_core import Address, NetworkGlobalID
from tonutils.clients import ToncenterClient
from tonutils.contracts import JettonTransferBuilder, TONTransferBuilder
from pyfragment.core.constants import USDT_TON_MASTER_ADDRESS, WALLET_CLASSES
from pyfragment.exceptions import TransactionError, WalletError
from pyfragment.models.wallet import TonTransferResult, UsdtTransferResult
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
async def send_ton_transfer(
client: FragmentClient,
destination: str,
amount: int,
body: str | None = None,
) -> TonTransferResult:
"""Send a direct TON transfer from the seeded wallet.
Args:
client: Authenticated `FragmentClient` instance.
destination: Recipient address in any TON-compatible format.
amount: Amount in nanotons.
body: Optional on-chain comment.
"""
try:
async with ToncenterClient(network=NetworkGlobalID.MAINNET) as ton:
wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
result = await wallet.transfer_message(
TONTransferBuilder(
destination=Address(destination),
amount=amount,
body=body,
)
)
return TonTransferResult(
transaction_id=str(result.normalized_hash),
destination=destination,
amount=amount,
)
except (TransactionError, WalletError):
raise
except Exception as exc:
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc=exc)) from exc
async def send_usdt_transfer(
client: FragmentClient,
destination: str,
usdt_amount: int,
forward_payload: str | None = None,
ton_for_gas: int = 50_000_000,
) -> UsdtTransferResult:
"""Send a direct USDT transfer from the seeded wallet.
Args:
client: Authenticated `FragmentClient` instance.
destination: Recipient address in any TON-compatible format.
usdt_amount: Amount in USDT base units (6 decimals).
forward_payload: Optional comment passed through to the recipient.
ton_for_gas: TON attached for gas in nanotons.
"""
try:
async with ToncenterClient(network=NetworkGlobalID.MAINNET) as ton:
wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
result = await wallet.transfer_message(
JettonTransferBuilder(
destination=Address(destination),
jetton_amount=usdt_amount,
jetton_master_address=Address(USDT_TON_MASTER_ADDRESS),
forward_payload=forward_payload,
forward_amount=1,
amount=ton_for_gas,
)
)
return UsdtTransferResult(
transaction_id=str(result.normalized_hash),
destination=destination,
amount=usdt_amount,
)
except (TransactionError, WalletError):
raise
except Exception as exc:
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc=exc)) from exc
+1 -3
View File
@@ -4,7 +4,7 @@ from pyfragment.models.enums import PaymentMethod, WalletVersion
from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult
from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult
from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult, PremiumResult, StarsResult from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult, PremiumResult, StarsResult
from pyfragment.models.wallet import TonTransferResult, UsdtTransferResult, WalletInfo from pyfragment.models.wallet import WalletInfo
__all__ = [ __all__ = [
"AdsRechargeResult", "AdsRechargeResult",
@@ -19,9 +19,7 @@ __all__ = [
"StarsGiveawayResult", "StarsGiveawayResult",
"StarsResult", "StarsResult",
"TerminateSessionsResult", "TerminateSessionsResult",
"TonTransferResult",
"UsernamesResult", "UsernamesResult",
"UsdtTransferResult",
"WalletInfo", "WalletInfo",
"WalletVersion", "WalletVersion",
] ]
+1 -21
View File
@@ -17,24 +17,4 @@ class WalletInfo:
) )
@dataclass __all__ = ["WalletInfo"]
class TonTransferResult:
transaction_id: str
destination: str
amount: int
def __repr__(self) -> str:
return f"TonTransferResult(destination='{self.destination}', amount={self.amount} TON, tx='{self.transaction_id}')"
@dataclass
class UsdtTransferResult:
transaction_id: str
destination: str
amount: int
def __repr__(self) -> str:
return f"UsdtTransferResult(destination='{self.destination}', amount={self.amount} USDT, tx='{self.transaction_id}')"
__all__ = ["TonTransferResult", "UsdtTransferResult", "WalletInfo"]
+15 -8
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "pyfragment" name = "pyfragment"
version = "2026.3.0" version = "2026.3.1"
description = "Async Python client for the Fragment API. Buy Stars and Premium, top up TON and Ads balances, run giveaways, manage anonymous numbers, and search Fragment listings." description = "Async Python client for the Fragment API. Buy Stars and Premium, top up TON and Ads balances, run giveaways, manage anonymous numbers, and search Fragment listings."
readme = "README.md" readme = "README.md"
license = { text = "MIT" } license = { text = "MIT" }
@@ -21,7 +21,6 @@ keywords = [
"telegram-ads", "telegram-ads",
"ton", "ton",
"ton-blockchain", "ton-blockchain",
"tonkeeper",
"tonapi", "tonapi",
"anonymous-numbers", "anonymous-numbers",
"username-auctions", "username-auctions",
@@ -36,7 +35,6 @@ keywords = [
classifiers = [ classifiers = [
"Development Status :: 5 - Production/Stable", "Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers", "Intended Audience :: Developers",
"Intended Audience :: Financial and Insurance Industry",
"License :: OSI Approved :: MIT License", "License :: OSI Approved :: MIT License",
"Natural Language :: English", "Natural Language :: English",
"Operating System :: OS Independent", "Operating System :: OS Independent",
@@ -45,13 +43,13 @@ classifiers = [
"Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Framework :: AsyncIO", "Framework :: AsyncIO",
"Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries",
"Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Internet", "Topic :: Internet",
"Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP",
"Topic :: Office/Business :: Financial",
"Topic :: Office/Business :: Financial :: Investment",
"Typing :: Typed", "Typing :: Typed",
] ]
dependencies = ["httpx>=0.25", "tonutils>=2.0.1"] dependencies = ["httpx>=0.25", "tonutils>=2.0.1"]
@@ -78,7 +76,7 @@ addopts = "-v --tb=short"
[tool.ruff] [tool.ruff]
line-length = 128 line-length = 128
target-version = "py312" target-version = "py310"
[tool.ruff.lint] [tool.ruff.lint]
# E — pycodestyle errors, F — pyflakes, W — warnings, I — isort, UP — pyupgrade # E — pycodestyle errors, F — pyflakes, W — warnings, I — isort, UP — pyupgrade
@@ -89,9 +87,18 @@ ignore = ["E501", "UP017"]
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
"tests/*" = ["E402"] "tests/*" = ["E402"]
"systests/*" = ["E402"]
[tool.mypy] [tool.mypy]
python_version = "3.10" python_version = "3.10"
strict = true strict = true
exclude = ["^systests/", "^examples/"] exclude = ["^examples/"]
[[tool.mypy.overrides]]
module = "rookiepy"
ignore_missing_imports = true
[[tool.mypy.overrides]]
# rookiepy = None assignment is valid when the package is installed (type: ModuleType)
# but mypy on CI (without rookiepy) sees it as Any and flags the comment as unused.
module = "pyfragment.core.cookies"
warn_unused_ignores = false
+5 -4
View File
@@ -1,12 +1,11 @@
"""Cover stars purchase and giveaway flows, including validation and request wiring.""" """Cover stars purchase and giveaway flows, including validation and request wiring."""
import importlib
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
_purchase_stars_mod = importlib.import_module("pyfragment.domains.purchases.purchase") import pyfragment.domains.giveaways.giveaway as _giveaway_stars_mod
_giveaway_stars_mod = importlib.import_module("pyfragment.domains.giveaways.giveaway") import pyfragment.domains.purchases.purchase as _purchase_stars_mod
from pyfragment import ConfigurationError, FragmentClient, StarsGiveawayResult, StarsResult, UserNotFoundError from pyfragment import ConfigurationError, FragmentClient, StarsGiveawayResult, StarsResult, UserNotFoundError
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
@@ -165,6 +164,7 @@ async def test_giveaway_stars_success(client: FragmentClient) -> None:
AsyncMock( AsyncMock(
side_effect=[ side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}}, {"found": {"recipient": FAKE_RECIPIENT}},
{},
{"req_id": FAKE_REQ_ID}, {"req_id": FAKE_REQ_ID},
FAKE_TRANSACTION, FAKE_TRANSACTION,
] ]
@@ -187,6 +187,7 @@ async def test_giveaway_stars_passes_payment_method(client: FragmentClient) -> N
call_mock = AsyncMock( call_mock = AsyncMock(
side_effect=[ side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}}, {"found": {"recipient": FAKE_RECIPIENT}},
{},
{"req_id": FAKE_REQ_ID}, {"req_id": FAKE_REQ_ID},
FAKE_TRANSACTION, FAKE_TRANSACTION,
] ]
@@ -199,7 +200,7 @@ async def test_giveaway_stars_passes_payment_method(client: FragmentClient) -> N
): ):
await client.giveaway_stars("@channel", winners=3, amount=1000, payment_method="usdt_ton") await client.giveaway_stars("@channel", winners=3, amount=1000, payment_method="usdt_ton")
init_call = call_mock.await_args_list[1] init_call = call_mock.await_args_list[2]
assert init_call.args[0] == "initGiveawayStarsRequest" assert init_call.args[0] == "initGiveawayStarsRequest"
assert init_call.args[1]["payment_method"] == "usdt_ton" assert init_call.args[1]["payment_method"] == "usdt_ton"
assert proc_mock.await_args is not None assert proc_mock.await_args is not None
+5 -4
View File
@@ -1,12 +1,11 @@
"""Cover premium purchase and giveaway flows, including validation and request wiring.""" """Cover premium purchase and giveaway flows, including validation and request wiring."""
import importlib
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
_purchase_premium_mod = importlib.import_module("pyfragment.domains.purchases.purchase") import pyfragment.domains.giveaways.giveaway as _giveaway_premium_mod
_giveaway_premium_mod = importlib.import_module("pyfragment.domains.giveaways.giveaway") import pyfragment.domains.purchases.purchase as _purchase_premium_mod
from pyfragment import ConfigurationError, FragmentClient, PremiumGiveawayResult, PremiumResult, UserNotFoundError from pyfragment import ConfigurationError, FragmentClient, PremiumGiveawayResult, PremiumResult, UserNotFoundError
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
@@ -150,6 +149,7 @@ async def test_giveaway_premium_success(client: FragmentClient) -> None:
AsyncMock( AsyncMock(
side_effect=[ side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}}, {"found": {"recipient": FAKE_RECIPIENT}},
{},
{"req_id": FAKE_REQ_ID}, {"req_id": FAKE_REQ_ID},
FAKE_TRANSACTION, FAKE_TRANSACTION,
] ]
@@ -172,6 +172,7 @@ async def test_giveaway_premium_passes_payment_method(client: FragmentClient) ->
call_mock = AsyncMock( call_mock = AsyncMock(
side_effect=[ side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}}, {"found": {"recipient": FAKE_RECIPIENT}},
{},
{"req_id": FAKE_REQ_ID}, {"req_id": FAKE_REQ_ID},
FAKE_TRANSACTION, FAKE_TRANSACTION,
] ]
@@ -184,7 +185,7 @@ async def test_giveaway_premium_passes_payment_method(client: FragmentClient) ->
): ):
await client.giveaway_premium("@channel", winners=10, months=6, payment_method="usdt_ton") await client.giveaway_premium("@channel", winners=10, months=6, payment_method="usdt_ton")
init_call = call_mock.await_args_list[1] init_call = call_mock.await_args_list[2]
assert init_call.args[0] == "initGiveawayPremiumRequest" assert init_call.args[0] == "initGiveawayPremiumRequest"
assert init_call.args[1]["payment_method"] == "usdt_ton" assert init_call.args[1]["payment_method"] == "usdt_ton"
assert proc_mock.await_args is not None assert proc_mock.await_args is not None
+1 -2
View File
@@ -1,11 +1,10 @@
"""Cover TON top-up through Telegram Ads, including recipient lookup and transaction building.""" """Cover TON top-up through Telegram Ads, including recipient lookup and transaction building."""
import importlib
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
_topup_ton_mod = importlib.import_module("pyfragment.domains.ads.tonup") import pyfragment.domains.ads.tonup as _topup_ton_mod
from pyfragment import AdsTopupResult, ConfigurationError, FragmentClient, UserNotFoundError from pyfragment import AdsTopupResult, ConfigurationError, FragmentClient, UserNotFoundError
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
+8 -8
View File
@@ -15,8 +15,8 @@ from tests.shared import FAKE_HASH, FAKE_RESPONSE
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_call_returns_api_response(client: FragmentClient) -> None: async def test_call_returns_api_response(client: FragmentClient) -> None:
with ( with (
patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), patch("pyfragment.domains.base.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch("pyfragment.client.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)), patch("pyfragment.domains.base.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)),
): ):
result = await client.call("anyMethod", {"key": "value"}) result = await client.call("anyMethod", {"key": "value"})
@@ -26,8 +26,8 @@ async def test_call_returns_api_response(client: FragmentClient) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_call_default_page_url(client: FragmentClient) -> None: async def test_call_default_page_url(client: FragmentClient) -> None:
with ( with (
patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), patch("pyfragment.domains.base.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch("pyfragment.client.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)), patch("pyfragment.domains.base.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)),
): ):
result = await client.call("anyMethod") result = await client.call("anyMethod")
@@ -39,8 +39,8 @@ async def test_call_no_data(client: FragmentClient) -> None:
mock_request = AsyncMock(return_value={}) mock_request = AsyncMock(return_value={})
with ( with (
patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), patch("pyfragment.domains.base.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch("pyfragment.client.fragment_request", mock_request), patch("pyfragment.domains.base.fragment_request", mock_request),
): ):
await client.call("anyMethod") await client.call("anyMethod")
@@ -53,8 +53,8 @@ async def test_call_merges_extra_data(client: FragmentClient) -> None:
mock_request = AsyncMock(return_value={}) mock_request = AsyncMock(return_value={})
with ( with (
patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), patch("pyfragment.domains.base.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch("pyfragment.client.fragment_request", mock_request), patch("pyfragment.domains.base.fragment_request", mock_request),
): ):
await client.call("anyMethod", {"key": "value", "num": 7}) await client.call("anyMethod", {"key": "value", "num": 7})
+1 -2
View File
@@ -1,11 +1,10 @@
"""Cover Telegram Ads recharge flow, including request preparation and KYC handling.""" """Cover Telegram Ads recharge flow, including request preparation and KYC handling."""
import importlib
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
_recharge_ads_mod = importlib.import_module("pyfragment.domains.ads.recharge") import pyfragment.domains.ads.recharge as _recharge_ads_mod
from pyfragment import AdsRechargeResult, ConfigurationError, FragmentClient from pyfragment import AdsRechargeResult, ConfigurationError, FragmentClient
from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH