11 Commits

Author SHA1 Message Date
Bohdan 👾 eec59f7e12 Merge pull request #20 from bohd4nx/dev
Dev
2026-05-03 19:21:35 +03:00
bohd4nx c1ae5f0f28 fix: update download-artifact action version to v8 for consistency 2026-05-03 19:20:30 +03:00
bohd4nx 4873d6d6dd fix: update PyPI publish action to use the latest release version
Co-authored-by: Copilot <copilot@github.com>
2026-05-03 19:19:54 +03:00
Bohdan 👾 1f6085acdc Merge pull request #18 from bohd4nx/dev
Dev
2026-05-03 19:16:19 +03:00
bohd4nx d5dfed5f1a fix: update CI and publish workflows to use consistent action versions and improve error handling in API requests
Co-authored-by: Copilot <copilot@github.com>
2026-05-03 19:10:11 +03:00
bohd4nx 7923dff8b2 fix: remove unnecessary 'method' key from API request data and improve error handling for expired cookies and non-200 HTTP responses 2026-04-21 20:06:11 +03:00
bohd4nx 8c7423a6ab fix: update Python version in CI workflow and refine project metadata for clarity 2026-04-18 21:11:14 +03:00
bohd4nx 8f3e9f4bd8 chore: update action versions in CI workflow and adjust linter ignores for compatibility 2026-04-18 21:05:12 +03:00
bohd4nx fca60135a6 refactor: update type hints for better clarity and consistency across the codebase 2026-04-18 21:02:53 +03:00
bohd4nx d66602b646 fix: replace Miniconda setup with Python setup in CI workflow for consistency 2026-04-14 02:03:22 +03:00
bohd4nx 333b6f45ce fix: update Miniconda setup action version and adjust activation settings 2026-04-14 02:01:16 +03:00
29 changed files with 384 additions and 299 deletions
+86 -86
View File
@@ -1,101 +1,101 @@
name: Bug report name: Bug report
description: Report an issue or unexpected behavior in pyfragment. description: Report an issue or unexpected behavior in pyfragment.
labels: labels:
- bug - bug
body: body:
- type: checkboxes - type: checkboxes
attributes: attributes:
label: Checklist label: Checklist
options: options:
- label: I am sure the error is coming from pyfragment code - label: I am sure the error is coming from pyfragment code
required: true required: true
- label: I have searched the issue tracker for similar bug reports, including closed ones - label: I have searched the issue tracker for similar bug reports, including closed ones
required: true
- type: markdown
attributes:
value: |
## Context
Please provide as much detail as possible to help us reproduce and fix the issue.
- type: input
attributes:
label: Operating system
placeholder: e.g. Ubuntu 22.04 / macOS 14 / Windows 11
validations:
required: true required: true
- type: input - type: markdown
attributes: attributes:
label: Python version value: |
description: Run `python --version` inside your virtualenv ## Context
placeholder: e.g. 3.12.3 Please provide as much detail as possible to help us reproduce and fix the issue.
validations:
required: true
- type: input - type: input
attributes: attributes:
label: pyfragment version label: Operating system
description: Run `pip show pyfragment` inside your virtualenv placeholder: e.g. Ubuntu 22.04 / macOS 14 / Windows 11
placeholder: e.g. 2026.1.0 validations:
validations: required: true
required: true
- type: textarea - type: input
attributes: attributes:
label: Expected behavior label: Python version
description: Describe what you expected to happen. description: Run `python --version` inside your virtualenv
placeholder: e.g. Stars should be purchased and StarsResult returned. placeholder: e.g. 3.12.3
validations: validations:
required: true required: true
- type: textarea - type: input
attributes: attributes:
label: Current behavior label: pyfragment version
description: Describe what is actually happening. description: Run `pip show pyfragment` inside your virtualenv
placeholder: e.g. ParseError is raised with status 400. placeholder: e.g. 2026.1.0
validations: validations:
required: true required: true
- type: textarea - type: textarea
attributes: attributes:
label: Steps to reproduce label: Expected behavior
description: Minimal steps that reproduce the issue. description: Describe what you expected to happen.
placeholder: | placeholder: e.g. Stars should be purchased and StarsResult returned.
1. Create FragmentClient with valid credentials validations:
2. Call purchase_stars("@username", amount=100) required: true
3. See error
validations:
required: true
- type: textarea - type: textarea
attributes: attributes:
label: Code example label: Current behavior
description: Provide a [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) if applicable. description: Describe what is actually happening.
placeholder: | placeholder: e.g. ParseError is raised with status 400.
import asyncio validations:
from pyfragment import FragmentClient required: true
async def main(): - type: textarea
client = FragmentClient(...) attributes:
result = await client.purchase_stars("@username", amount=100) label: Steps to reproduce
description: Minimal steps that reproduce the issue.
placeholder: |
1. Create FragmentClient with valid credentials
2. Call purchase_stars("@username", amount=100)
3. See error
validations:
required: true
asyncio.run(main()) - type: textarea
render: python attributes:
label: Code example
description: Provide a [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) if applicable.
placeholder: |
import asyncio
from pyfragment import FragmentClient
- type: textarea async def main():
attributes: client = FragmentClient(...)
label: Traceback / logs result = await client.purchase_stars("@username", amount=100)
description: Paste the full traceback or relevant logs.
placeholder: |
Traceback (most recent call last):
File "main.py", line 7, in main
...
pyfragment.types.ParseError: ...
render: sh
- type: textarea asyncio.run(main())
attributes: render: python
label: Additional information
description: Anything else that might help us diagnose the problem. - type: textarea
placeholder: e.g. Only happens with V5R1 wallet version. attributes:
label: Traceback / logs
description: Paste the full traceback or relevant logs.
placeholder: |
Traceback (most recent call last):
File "main.py", line 7, in main
...
pyfragment.types.ParseError: ...
render: sh
- type: textarea
attributes:
label: Additional information
description: Anything else that might help us diagnose the problem.
placeholder: e.g. Only happens with V5R1 wallet version.
+3 -3
View File
@@ -1,5 +1,5 @@
blank_issues_enabled: true blank_issues_enabled: true
contact_links: contact_links:
- name: Ask a question or start a discussion - name: Ask a question or start a discussion
url: https://github.com/bohd4nx/pyfragment/discussions url: https://github.com/bohd4nx/pyfragment/discussions
about: General questions, ideas, and community help go here — not in the issue tracker. about: General questions, ideas, and community help go here — not in the issue tracker.
+42 -42
View File
@@ -1,51 +1,51 @@
name: Feature request name: Feature request
description: Suggest an improvement or new feature for pyfragment. description: Suggest an improvement or new feature for pyfragment.
labels: labels:
- enhancement - enhancement
body: body:
- type: dropdown - type: dropdown
attributes: attributes:
label: pyfragment version label: pyfragment version
description: Which version are you running? description: Which version are you running?
options: options:
- latest - latest
- older - older
- n/a - n/a
validations: validations:
required: true required: true
- type: textarea - type: textarea
attributes: attributes:
label: Problem label: Problem
description: Is your request related to a specific problem? Describe it. description: Is your request related to a specific problem? Describe it.
placeholder: e.g. There is no way to check my current TON balance before sending. placeholder: e.g. There is no way to check my current TON balance before sending.
validations: validations:
required: true required: true
- type: textarea - type: textarea
attributes: attributes:
label: Proposed solution label: Proposed solution
description: Describe what you would like to see added or changed. description: Describe what you would like to see added or changed.
placeholder: e.g. Add a get_balance() method to FragmentClient. placeholder: e.g. Add a get_balance() method to FragmentClient.
validations: validations:
required: true required: true
- type: textarea - type: textarea
attributes: attributes:
label: Alternatives considered label: Alternatives considered
description: Any workarounds or alternative approaches you have thought of. description: Any workarounds or alternative approaches you have thought of.
placeholder: e.g. I manually call the Fragment API, but it's not ergonomic. placeholder: e.g. I manually call the Fragment API, but it's not ergonomic.
- type: textarea - type: textarea
attributes: attributes:
label: Code example label: Code example
description: A short example demonstrating the desired API, if applicable. description: A short example demonstrating the desired API, if applicable.
placeholder: | placeholder: |
balance = await client.get_balance() balance = await client.get_balance()
print(balance.ton) print(balance.ton)
render: python render: python
- type: textarea - type: textarea
attributes: attributes:
label: Additional information label: Additional information
description: Any other context, screenshots, or references. description: Any other context, screenshots, or references.
+16 -16
View File
@@ -1,19 +1,19 @@
version: 2 version: 2
updates: updates:
- package-ecosystem: "pip" - package-ecosystem: "pip"
directory: "/" directory: "/"
schedule: schedule:
interval: "weekly" interval: "weekly"
day: "monday" day: "monday"
open-pull-requests-limit: 5 open-pull-requests-limit: 5
labels: labels:
- "dependencies" - "dependencies"
- package-ecosystem: "github-actions" - package-ecosystem: "github-actions"
directory: "/" directory: "/"
schedule: schedule:
interval: "weekly" interval: "weekly"
day: "monday" day: "monday"
open-pull-requests-limit: 5 open-pull-requests-limit: 5
labels: labels:
- "dependencies" - "dependencies"
+17 -23
View File
@@ -2,9 +2,9 @@ name: CI
on: on:
push: push:
branches: ["**"] branches: [ "**" ]
pull_request: pull_request:
branches: ["**"] branches: [ "**" ]
jobs: jobs:
lint: lint:
@@ -14,15 +14,15 @@ jobs:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
steps: steps:
- uses: actions/checkout@v6.0.2 - uses: actions/checkout@v6
- uses: actions/setup-python@v6.2.0 - uses: actions/setup-python@v6
with: with:
python-version: "3.12" python-version: "3.10"
- run: pip install ".[dev]" - run: pip install ".[dev]"
- run: ruff check . && black --check . --target-version py312 && mypy pyfragment - run: ruff check . && ruff format --check . && mypy pyfragment
test: test:
name: Tests (Python ${{ matrix.python-version }}) name: Tests (Python ${{ matrix.python-version }})
@@ -33,23 +33,17 @@ jobs:
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 are not supported by some dependencies yet
steps: steps:
- uses: actions/checkout@v6.0.2 - uses: actions/checkout@v6
- name: Set up conda (Miniconda) - uses: actions/setup-python@v6
uses: conda-incubator/setup-miniconda@v3 with:
with: python-version: ${{ matrix.python-version }}
auto-update-conda: true
python-version: ${{ matrix.python-version }}
activate-environment: pyfragment-test
auto-activate-base: false
- name: Install package and dev dependencies - name: Install package and dev dependencies
shell: bash -el {0} run: pip install ".[dev]"
run: pip install ".[dev]"
- name: Run tests - name: Run tests
shell: bash -el {0} run: pytest
run: pytest
+57 -57
View File
@@ -2,9 +2,9 @@ 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:
@@ -18,24 +18,24 @@ jobs:
is-new: ${{ steps.tag.outputs.is-new }} is-new: ${{ steps.tag.outputs.is-new }}
steps: steps:
- uses: actions/checkout@v6.0.2 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Read version - name: Read version
id: version id: version
run: | run: |
value=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/') value=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/')
echo "value=$value" >> $GITHUB_OUTPUT echo "value=$value" >> $GITHUB_OUTPUT
- name: Check tag - name: Check tag
id: tag id: tag
run: | run: |
if git ls-remote --tags origin "refs/tags/v${{ steps.version.outputs.value }}" | grep -q .; then if git ls-remote --tags origin "refs/tags/v${{ steps.version.outputs.value }}" | grep -q .; then
echo "is-new=false" >> $GITHUB_OUTPUT echo "is-new=false" >> $GITHUB_OUTPUT
else else
echo "is-new=true" >> $GITHUB_OUTPUT echo "is-new=true" >> $GITHUB_OUTPUT
fi fi
build: build:
name: Build name: Build
@@ -46,24 +46,24 @@ jobs:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
steps: steps:
- uses: actions/checkout@v6.0.2 - uses: actions/checkout@v6
- uses: actions/setup-python@v6.2.0 - uses: actions/setup-python@v6
with: with:
python-version: "3.12" python-version: "3.12"
- uses: astral-sh/setup-uv@v7.6.0 - uses: astral-sh/setup-uv@v8.1.0
- run: uv build - run: uv build
- uses: actions/upload-artifact@v7 - uses: actions/upload-artifact@v4
with: with:
name: dist name: dist
path: dist/* path: dist/*
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: env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
@@ -74,16 +74,16 @@ jobs:
id-token: write id-token: write
steps: steps:
- uses: actions/download-artifact@v8.0.1 - uses: actions/download-artifact@v8
with: with:
name: dist name: dist
path: dist path: dist
- uses: pypa/gh-action-pypi-publish@v1.13.0 - uses: pypa/gh-action-pypi-publish@release/v1
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: env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
@@ -91,27 +91,27 @@ jobs:
contents: write contents: write
steps: steps:
- uses: actions/checkout@v6.0.2 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: actions/download-artifact@v8.0.1 - uses: actions/download-artifact@v8
with: with:
name: dist name: dist
path: dist path: dist
- name: Extract latest changelog entry - name: Extract latest changelog entry
id: changelog id: changelog
run: | run: |
body=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md) body=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
echo "body<<EOF" >> $GITHUB_OUTPUT echo "body<<EOF" >> $GITHUB_OUTPUT
echo "$body" >> $GITHUB_OUTPUT echo "$body" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT
- uses: softprops/action-gh-release@v2.6.1 - 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 }} body: ${{ steps.changelog.outputs.body }}
make_latest: true make_latest: true
+16
View File
@@ -7,6 +7,21 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
--- ---
## [2026.2.1] — 2026-05-03
### Fixed
- Fragment API 429 responses are now retried automatically (up to 3 attempts) with exponential backoff and jitter in `fragment_request`
- Retry delays in TON transaction broadcasting now include jitter to reduce contention under concurrent calls
- Improved handling of non-200 HTTP responses in `get_fragment_hash`
- Removed unnecessary `method` key leaking into certain API request payloads
### Changed
- Type hints refined across the codebase for better clarity and `mypy` strict compliance
---
## [2026.2.0] — 2026-04-14 ## [2026.2.0] — 2026-04-14
### Added ### Added
@@ -104,6 +119,7 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
- `py.typed` marker — full PEP 561 typing support for type-checkers - `py.typed` marker — full PEP 561 typing support for type-checkers
- `__repr__` on all result types for readable debug output - `__repr__` on all result types for readable debug output
[2026.2.1]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.1
[2026.2.0]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.0 [2026.2.0]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.0
[2026.1.0]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.1.0 [2026.1.0]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.1.0
[2026.0.2]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.0.2 [2026.0.2]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.0.2
+4 -4
View File
@@ -77,7 +77,7 @@ class FragmentClient:
self, self,
seed: str, seed: str,
api_key: str, api_key: str,
cookies: dict | str, cookies: dict[str, Any] | str,
wallet_version: str = "V5R1", wallet_version: str = "V5R1",
timeout: float = DEFAULT_TIMEOUT, timeout: float = DEFAULT_TIMEOUT,
) -> None: ) -> None:
@@ -98,7 +98,7 @@ class FragmentClient:
except Exception as exc: except Exception as exc:
raise CookieError(CookieError.READ_FAILED.format(exc=exc)) from 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, cookies).get(k, "")).strip()] 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)))
@@ -112,11 +112,11 @@ class FragmentClient:
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 = cast(dict, cookies) self.cookies: dict[str, Any] = cast(dict[str, Any], cookies)
self.wallet_version: WalletVersion = version # type: ignore[assignment] self.wallet_version: WalletVersion = version # type: ignore[assignment]
self.timeout: float = timeout self.timeout: float = timeout
async def __aenter__(self) -> "FragmentClient": async def __aenter__(self) -> FragmentClient:
return self return self
async def __aexit__(self, *_: object) -> None: async def __aexit__(self, *_: object) -> None:
+3 -3
View File
@@ -22,7 +22,7 @@ def _strip_plus(number: str) -> str:
return number.lstrip("+") if isinstance(number, str) else number return number.lstrip("+") if isinstance(number, str) else number
async def get_login_code(client: "FragmentClient", number: str) -> LoginCodeResult: async def get_login_code(client: FragmentClient, number: str) -> LoginCodeResult:
"""Fetch the current pending login code for an anonymous number. """Fetch the current pending login code for an anonymous number.
Args: Args:
@@ -58,7 +58,7 @@ async def get_login_code(client: "FragmentClient", number: str) -> LoginCodeResu
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
async def toggle_login_codes(client: "FragmentClient", number: str, can_receive: bool) -> None: async def toggle_login_codes(client: FragmentClient, number: str, can_receive: bool) -> None:
"""Enable or disable login code delivery for an anonymous number. """Enable or disable login code delivery for an anonymous number.
Args: Args:
@@ -87,7 +87,7 @@ async def toggle_login_codes(client: "FragmentClient", number: str, can_receive:
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
async def terminate_sessions(client: "FragmentClient", number: str) -> TerminateSessionsResult: async def terminate_sessions(client: FragmentClient, number: str) -> TerminateSessionsResult:
"""Terminate all active Telegram sessions for an anonymous number. """Terminate all active Telegram sessions for an anonymous number.
This is a two-step operation: Fragment first returns a confirmation hash, This is a two-step operation: Fragment first returns a confirmation hash,
+1 -1
View File
@@ -20,7 +20,7 @@ if TYPE_CHECKING:
async def giveaway_premium( async def giveaway_premium(
client: "FragmentClient", client: FragmentClient,
channel: str, channel: str,
winners: int, winners: int,
months: int = 3, months: int = 3,
+1 -1
View File
@@ -20,7 +20,7 @@ if TYPE_CHECKING:
async def giveaway_stars( async def giveaway_stars(
client: "FragmentClient", client: FragmentClient,
channel: str, channel: str,
winners: int, winners: int,
amount: int, amount: int,
+1 -1
View File
@@ -20,7 +20,7 @@ if TYPE_CHECKING:
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
async def purchase_premium(client: "FragmentClient", username: str, months: int, show_sender: bool = True) -> PremiumResult: async def purchase_premium(client: FragmentClient, username: str, months: int, show_sender: bool = True) -> PremiumResult:
"""Gift Telegram Premium to a user. """Gift Telegram Premium to a user.
Args: Args:
+1 -1
View File
@@ -19,7 +19,7 @@ if TYPE_CHECKING:
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
async def purchase_stars(client: "FragmentClient", username: str, amount: int, show_sender: bool = True) -> StarsResult: async def purchase_stars(client: FragmentClient, username: str, amount: int, show_sender: bool = True) -> StarsResult:
"""Send Telegram Stars to a user. """Send Telegram Stars to a user.
Args: Args:
+1 -1
View File
@@ -18,7 +18,7 @@ if TYPE_CHECKING:
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
async def recharge_ads(client: "FragmentClient", account: str, amount: int) -> AdsRechargeResult: async def recharge_ads(client: FragmentClient, account: str, amount: int) -> AdsRechargeResult:
"""Add funds to your own Telegram Ads account. """Add funds to your own Telegram Ads account.
Args: Args:
+2 -2
View File
@@ -11,7 +11,7 @@ if TYPE_CHECKING:
async def search_gifts( async def search_gifts(
client: "FragmentClient", client: FragmentClient,
query: str = "", query: str = "",
collection: str | None = None, collection: str | None = None,
sort: str | None = None, sort: str | None = None,
@@ -45,7 +45,7 @@ async def search_gifts(
FragmentAPIError: If the Fragment API returns an error. FragmentAPIError: If the Fragment API returns an error.
UnexpectedError: For any other unexpected failure. UnexpectedError: For any other unexpected failure.
""" """
data: dict[str, Any] = {"method": "searchAuctions", "type": "gifts", "query": query} data: dict[str, Any] = {"type": "gifts", "query": query}
if collection is not None: if collection is not None:
data["collection"] = collection data["collection"] = collection
if sort is not None: if sort is not None:
+2 -2
View File
@@ -11,7 +11,7 @@ if TYPE_CHECKING:
async def search_numbers( async def search_numbers(
client: "FragmentClient", client: FragmentClient,
query: str = "", query: str = "",
sort: str | None = None, sort: str | None = None,
filter: str | None = None, filter: str | None = None,
@@ -37,7 +37,7 @@ async def search_numbers(
FragmentAPIError: If the Fragment API returns an error. FragmentAPIError: If the Fragment API returns an error.
UnexpectedError: For any other unexpected failure. UnexpectedError: For any other unexpected failure.
""" """
data: dict[str, Any] = {"method": "searchAuctions", "type": "numbers", "query": query} data: dict[str, Any] = {"type": "numbers", "query": query}
if sort is not None: if sort is not None:
data["sort"] = sort data["sort"] = sort
if filter is not None: if filter is not None:
+2 -2
View File
@@ -11,7 +11,7 @@ if TYPE_CHECKING:
async def search_usernames( async def search_usernames(
client: "FragmentClient", client: FragmentClient,
query: str = "", query: str = "",
sort: str | None = None, sort: str | None = None,
filter: str | None = None, filter: str | None = None,
@@ -37,7 +37,7 @@ async def search_usernames(
FragmentAPIError: If the Fragment API returns an error. FragmentAPIError: If the Fragment API returns an error.
UnexpectedError: For any other unexpected failure. UnexpectedError: For any other unexpected failure.
""" """
data: dict[str, Any] = {"method": "searchAuctions", "type": "usernames", "query": query} data: dict[str, Any] = {"type": "usernames", "query": query}
if sort is not None: if sort is not None:
data["sort"] = sort data["sort"] = sort
if filter is not None: if filter is not None:
+1 -1
View File
@@ -19,7 +19,7 @@ if TYPE_CHECKING:
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
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:
"""Top up TON to a recipient's Telegram balance. """Top up TON to a recipient's Telegram balance.
Args: Args:
+5 -4
View File
@@ -16,7 +16,7 @@ class ConfigurationError(ClientError):
UNSUPPORTED_VERSION = "Unsupported wallet_version '{version}'. Must be one of: {supported}." UNSUPPORTED_VERSION = "Unsupported wallet_version '{version}'. Must be one of: {supported}."
INVALID_MNEMONIC = "Invalid mnemonic: expected 12, 18, or 24 words, got {count}." INVALID_MNEMONIC = "Invalid mnemonic: expected 12, 18, or 24 words, got {count}."
INVALID_API_KEY = ( INVALID_API_KEY = (
"Invalid Tonapi API key: expected at least 68 characters, got {length}. " "Generate a key at https://tonconsole.com." "Invalid Tonapi API key: expected at least 68 characters, got {length}. Generate a key at https://tonconsole.com."
) )
INVALID_MONTHS = "Invalid Premium duration: choose 3, 6, or 12 months." INVALID_MONTHS = "Invalid Premium duration: choose 3, 6, or 12 months."
INVALID_STARS_AMOUNT = "Invalid Stars amount: must be an integer between 50 and 1 000 000." INVALID_STARS_AMOUNT = "Invalid Stars amount: must be an integer between 50 and 1 000 000."
@@ -40,12 +40,13 @@ class CookieError(ClientError):
) )
UNSUPPORTED_BROWSER = "Unsupported browser: '{browser}'. Supported: {supported}." UNSUPPORTED_BROWSER = "Unsupported browser: '{browser}'. Supported: {supported}."
BROWSER_READ_FAILED = ( BROWSER_READ_FAILED = (
"Failed to read {browser} cookies: {exc}. " "Make sure {browser} is installed and you are logged in to {url}." "Failed to read {browser} cookies: {exc}. Make sure {browser} is installed and you are logged in to {url}."
) )
MISSING_BROWSER_KEYS = ( MISSING_BROWSER_KEYS = (
"Fragment cookies not found in {browser}: {keys}. " "Fragment cookies not found in {browser}: {keys}. "
"Make sure you are logged in to {url} and have connected your TON wallet in {browser}." "Make sure you are logged in to {url} and have connected your TON wallet in {browser}."
) )
EXPIRED = "Fragment session cookie expired at {expires}. Log in to fragment.com in your browser and extract fresh cookies."
class FragmentAPIError(FragmentError): class FragmentAPIError(FragmentError):
@@ -74,7 +75,7 @@ class UserNotFoundError(FragmentAPIError):
"""Raised when the target Telegram user is not found on Fragment.""" """Raised when the target Telegram user is not found on Fragment."""
NOT_FOUND = ( NOT_FOUND = (
"Telegram user '{username}' was not found on Fragment. " "Double-check the username and make sure the account exists." "Telegram user '{username}' was not found on Fragment. Double-check the username and make sure the account exists."
) )
@@ -89,7 +90,7 @@ class TransactionError(FragmentAPIError):
"""Raised when a TON transaction fails to build or broadcast.""" """Raised when a TON transaction fails to build or broadcast."""
INVALID_PAYLOAD = ( INVALID_PAYLOAD = (
"Fragment returned an invalid transaction payload — " "'transaction.messages' is missing or empty in the API response." "Fragment returned an invalid transaction payload — 'transaction.messages' is missing or empty in the API response."
) )
BROADCAST_FAILED = "Transaction broadcast failed: {exc}" BROADCAST_FAILED = "Transaction broadcast failed: {exc}"
BROADCAST_FAILED_SSL = ( BROADCAST_FAILED_SSL = (
+9 -2
View File
@@ -1,10 +1,12 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any
import rookiepy import rookiepy
from pyfragment.types import CookieError, CookieResult from pyfragment.types import CookieError
from pyfragment.types import CookieResult as CookieResult
from pyfragment.types.constants import FRAGMENT_BASE_URL, FRAGMENT_DOMAIN, REQUIRED_COOKIE_KEYS, SUPPORTED_BROWSERS from pyfragment.types.constants import FRAGMENT_BASE_URL, FRAGMENT_DOMAIN, REQUIRED_COOKIE_KEYS, SUPPORTED_BROWSERS
@@ -34,7 +36,7 @@ def get_cookies_from_browser(browser: str = "chrome") -> CookieResult:
raise CookieError(CookieError.UNSUPPORTED_BROWSER.format(browser=browser, supported=supported)) raise CookieError(CookieError.UNSUPPORTED_BROWSER.format(browser=browser, supported=supported))
try: try:
jar: list[dict] = getattr(rookiepy, key)([FRAGMENT_DOMAIN]) jar: list[dict[str, Any]] = getattr(rookiepy, key)([FRAGMENT_DOMAIN])
except Exception as exc: except Exception as exc:
raise CookieError(CookieError.BROWSER_READ_FAILED.format(browser=browser, exc=exc, url=FRAGMENT_BASE_URL)) from exc raise CookieError(CookieError.BROWSER_READ_FAILED.format(browser=browser, exc=exc, url=FRAGMENT_BASE_URL)) from exc
@@ -59,6 +61,11 @@ def get_cookies_from_browser(browser: str = "chrome") -> CookieResult:
continue continue
break break
if expires_iso:
expires_dt = datetime.fromisoformat(expires_iso)
if expires_dt < datetime.now(timezone.utc):
raise CookieError(CookieError.EXPIRED.format(expires=expires_iso))
return CookieResult( return CookieResult(
cookies={k: cookie_map[k] for k in REQUIRED_COOKIE_KEYS}, cookies={k: cookie_map[k] for k in REQUIRED_COOKIE_KEYS},
expires=expires_iso, expires=expires_iso,
+19 -8
View File
@@ -1,7 +1,9 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import random
import re import re
from typing import Any from typing import Any, cast
import httpx import httpx
@@ -80,7 +82,7 @@ def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any
ParseError: If the response body cannot be decoded as JSON. ParseError: If the response body cannot be decoded as JSON.
""" """
try: try:
return response.json() return cast(dict[str, Any], response.json())
except Exception as exc: except Exception as exc:
raise ParseError(ParseError.UNPARSEABLE.format(context=context, exc=exc)) from exc raise ParseError(ParseError.UNPARSEABLE.format(context=context, exc=exc)) from exc
@@ -106,12 +108,21 @@ async def fragment_request(
Returns: Returns:
Parsed API response as a dict. Parsed API response as a dict.
""" """
resp = await session.post( for attempt in range(3):
f"{FRAGMENT_BASE_URL}/api?hash={fragment_hash}", resp = await session.post(
headers=headers, f"{FRAGMENT_BASE_URL}/api?hash={fragment_hash}",
data=data, headers=headers,
) data=data,
return parse_json_response(resp, data.get("method", "request")) )
if resp.status_code == 429 and attempt < 2:
await asyncio.sleep(1 + attempt + random.uniform(0, 0.5))
continue
if resp.status_code != 200:
raise FragmentPageError(
FragmentPageError.BAD_STATUS.format(status=resp.status_code, url=f"{FRAGMENT_BASE_URL}/api")
)
return parse_json_response(resp, data.get("method", "request"))
raise FragmentPageError(FragmentPageError.BAD_STATUS.format(status=429, url=f"{FRAGMENT_BASE_URL}/api"))
async def execute_transaction_request( async def execute_transaction_request(
+8 -7
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio import asyncio
import base64 import base64
import random
import ssl import ssl
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
@@ -17,7 +18,7 @@ if TYPE_CHECKING:
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
async def process_transaction(client: "FragmentClient", transaction_data: dict[str, Any]) -> str: async def process_transaction(client: FragmentClient, transaction_data: dict[str, Any]) -> str:
"""Sign and broadcast a Fragment transaction to the TON network. """Sign and broadcast a Fragment transaction to the TON network.
Validates the payload structure, checks the wallet balance, decodes the Validates the payload structure, checks the wallet balance, decodes the
@@ -34,7 +35,7 @@ async def process_transaction(client: "FragmentClient", transaction_data: dict[s
TransactionError: If the payload is malformed or the broadcast fails. TransactionError: If the payload is malformed or the broadcast fails.
WalletError: If the wallet balance is too low or cannot be fetched. WalletError: If the wallet balance is too low or cannot be fetched.
""" """
if "transaction" not in transaction_data or "messages" not in transaction_data["transaction"]: if "transaction" not in transaction_data or not transaction_data["transaction"].get("messages"):
raise TransactionError(TransactionError.INVALID_PAYLOAD) raise TransactionError(TransactionError.INVALID_PAYLOAD)
message = transaction_data["transaction"]["messages"][0] message = transaction_data["transaction"]["messages"][0]
@@ -66,15 +67,15 @@ async def process_transaction(client: "FragmentClient", transaction_data: dict[s
amount=int(message["amount"]), # nanotons, not TON amount=int(message["amount"]), # nanotons, not TON
body=payload, body=payload,
) )
return result.normalized_hash return str(result.normalized_hash)
except ProviderResponseError as exc: except ProviderResponseError as exc:
if exc.code == 429 and attempt == 0: if exc.code == 429 and attempt == 0:
await asyncio.sleep(1) await asyncio.sleep(1 + random.uniform(0, 0.5))
continue continue
if exc.code == 406 and "seqno" in str(exc).lower(): if exc.code == 406 and "seqno" in str(exc).lower():
# Previous tx seqno not yet confirmed — wallet will re-fetch seqno on retry # Previous tx seqno not yet confirmed — wallet will re-fetch seqno on retry
if attempt < 2: if attempt < 2:
await asyncio.sleep(2) await asyncio.sleep(2 + random.uniform(0, 1))
continue continue
raise TransactionError(TransactionError.DUPLICATE_SEQNO) from exc raise TransactionError(TransactionError.DUPLICATE_SEQNO) from exc
raise raise
@@ -91,7 +92,7 @@ async def process_transaction(client: "FragmentClient", transaction_data: dict[s
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc="transfer loop exited without result")) raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc="transfer loop exited without result"))
async def get_account_info(client: "FragmentClient") -> dict[str, Any]: async def get_account_info(client: FragmentClient) -> dict[str, Any]:
"""Fetch wallet address, public key, and state-init for the Fragment API. """Fetch wallet address, public key, and state-init for the Fragment API.
Fragment requires account info to build each transaction payload. The Fragment requires account info to build each transaction payload. The
@@ -122,7 +123,7 @@ async def get_account_info(client: "FragmentClient") -> dict[str, Any]:
raise WalletError(WalletError.ACCOUNT_INFO_FAILED.format(exc=exc)) from exc raise WalletError(WalletError.ACCOUNT_INFO_FAILED.format(exc=exc)) from exc
async def get_wallet_info(client: "FragmentClient") -> "WalletInfo": async def get_wallet_info(client: FragmentClient) -> WalletInfo:
"""Return the address, state and balance of the TON wallet. """Return the address, state and balance of the TON wallet.
Args: Args:
+40 -25
View File
@@ -4,44 +4,56 @@ build-backend = "hatchling.build"
[project] [project]
name = "pyfragment" name = "pyfragment"
version = "2026.2.0" version = "2026.2.1"
description = "Async Python client for the Fragment API — a unified toolkit to manage Telegram assets: purchase Stars and Premium, top up TON and Ads balances, run giveaways, manage anonymous numbers, and explore the marketplace for usernames, numbers, and gifts." description = "Async Python client for the Fragment API — a unified toolkit to manage Telegram assets: purchase Stars and Premium, top up TON and Ads balances, run giveaways, manage anonymous numbers, and explore the marketplace for usernames, numbers, and gifts."
readme = "README.md" readme = "README.md"
license = { text = "MIT" } license = { text = "MIT" }
requires-python = ">=3.10" requires-python = ">=3.10"
authors = [{ name = "bohd4nx", url = "https://github.com/bohd4nx" }] authors = [{ name = "bohd4nx" }]
keywords = ["fragment", "telegram", "ton", "stars", "premium", "crypto", "blockchain"] keywords = [
"fragment",
"fragment-api",
"telegram",
"telegram-stars",
"telegram-premium",
"ton",
"tonkeeper",
"tonapi",
"crypto",
"blockchain",
"web3",
"giveaway",
"anonymous-number",
"username",
"nft",
"async",
"asyncio",
]
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",
"Programming Language :: Python",
"Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3 :: Only",
"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",
"Framework :: AsyncIO", "Framework :: AsyncIO",
"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 :: Office/Business :: Financial", "Topic :: Office/Business :: Financial",
"Topic :: Office/Business :: Financial :: Investment",
"Typing :: Typed", "Typing :: Typed",
] ]
dependencies = [ dependencies = ["httpx>=0.25", "rookiepy>=0.5.6", "tonutils>=2.0.1"]
"httpx==0.28.1",
"rookiepy==0.5.6",
"tonutils==2.1.0",
]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = ["pytest", "pytest-asyncio", "pytest-mock", "mypy", "ruff"]
"pytest==9.0.3",
"pytest-asyncio==1.3.0",
"pytest-mock",
"mypy",
"ruff",
"black",
]
[project.urls] [project.urls]
Homepage = "https://github.com/bohd4nx/pyfragment" Homepage = "https://github.com/bohd4nx/pyfragment"
@@ -53,24 +65,27 @@ Changelog = "https://github.com/bohd4nx/pyfragment/blob/master/CHANGELOG.md"
packages = ["pyfragment"] packages = ["pyfragment"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
python_files = ["[0-9][0-9][0-9]_test_*.py"] python_files = ["[0-9][0-9][0-9]_test_*.py"]
asyncio_mode = "auto" asyncio_mode = "auto"
addopts = "-v --tb=short" addopts = "-v --tb=short"
[tool.black]
line-length = 128
target-version = ["py312"]
[tool.ruff] [tool.ruff]
line-length = 128 line-length = 128
target-version = "py312" target-version = "py312"
[tool.ruff.lint] [tool.ruff.lint]
# E — pycodestyle errors, F — pyflakes, W — warnings, I — isort # E — pycodestyle errors, F — pyflakes, W — warnings, I — isort, UP — pyupgrade
select = ["E", "F", "W", "I"] select = ["E", "F", "W", "I", "UP"]
ignore = ["E501"] # E501 — line too long (covered by line-length above)
# UP017 — use datetime.UTC (only available in Python 3.11+, we support 3.10)
ignore = ["E501", "UP017"]
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
"tests/*" = ["E402"] "tests/*" = ["E402"]
"systests/*" = ["E402"] "systests/*" = ["E402"]
[tool.mypy]
python_version = "3.10"
strict = true
exclude = ["^systests/", "^examples/"]
-1
View File
@@ -1 +0,0 @@
-e .[dev]
-1
View File
@@ -1 +0,0 @@
-e .
+8 -1
View File
@@ -1,5 +1,6 @@
"""Unit tests for process_transaction() — balance validation and broadcast retry logic.""" """Unit tests for process_transaction() — balance validation and broadcast retry logic."""
from collections.abc import Generator
from contextlib import contextmanager from contextlib import contextmanager
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
@@ -45,7 +46,7 @@ def _make_wallet(balance_nanotons: int) -> MagicMock:
@contextmanager @contextmanager
def _patch_wallet(wallet: MagicMock): def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]:
with ( with (
patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi, patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi,
patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes, patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes,
@@ -102,6 +103,12 @@ async def test_invalid_payload_raises() -> None:
await process_transaction(_make_client(), {"transaction": {}}) await process_transaction(_make_client(), {"transaction": {}})
@pytest.mark.asyncio
async def test_empty_messages_list_raises() -> None:
with pytest.raises(TransactionError):
await process_transaction(_make_client(), {"transaction": {"messages": []}})
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_balance_check_failed_raises_wallet_error() -> None: async def test_balance_check_failed_raises_wallet_error() -> None:
wallet = _make_wallet(balance_nanotons=1_000_000_000) wallet = _make_wallet(balance_nanotons=1_000_000_000)
+20 -1
View File
@@ -1,10 +1,13 @@
"""Unit tests for FragmentClient.call() — raw Fragment API access.""" """Unit tests for FragmentClient.call() — raw Fragment API access."""
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest import pytest
from pyfragment import FragmentClient from pyfragment import FragmentClient
from pyfragment.types import FragmentPageError
from pyfragment.utils.http import fragment_request
from tests.shared import FAKE_HASH, FAKE_RESPONSE from tests.shared import FAKE_HASH, FAKE_RESPONSE
# client.call() mocked tests # client.call() mocked tests
@@ -61,3 +64,19 @@ async def test_call_merges_extra_data(client: FragmentClient) -> None:
_, _, _, sent_data = mock_request.call_args.args _, _, _, sent_data = mock_request.call_args.args
assert sent_data == {"method": "anyMethod", "key": "value", "num": 7} assert sent_data == {"method": "anyMethod", "key": "value", "num": 7}
# fragment_request HTTP status tests
@pytest.mark.asyncio
async def test_fragment_request_non_200_raises() -> None:
"""fragment_request raises FragmentPageError on non-200 HTTP responses."""
response = MagicMock(spec=httpx.Response)
response.status_code = 429
session = AsyncMock(spec=httpx.AsyncClient)
session.post = AsyncMock(return_value=response)
with pytest.raises(FragmentPageError, match="429"):
await fragment_request(session, FAKE_HASH, {}, {"method": "anyMethod"})
+16 -1
View File
@@ -17,7 +17,7 @@ FAKE_JAR = [
] ]
def _mock_rookiepy(jar: list[dict] | None = None) -> MagicMock: def _mock_rookiepy(jar: list[dict[str, str]] | None = None) -> MagicMock:
mock = MagicMock() mock = MagicMock()
mock.chrome.return_value = jar if jar is not None else FAKE_JAR mock.chrome.return_value = jar if jar is not None else FAKE_JAR
return mock return mock
@@ -106,6 +106,21 @@ def test_empty_cookie_value_treated_as_missing() -> None:
get_cookies_from_browser("chrome") get_cookies_from_browser("chrome")
# expired cookie tests
def test_expired_cookie_raises() -> None:
expired_jar = [
{"name": "stel_ssid", "value": "abc123", "expires": "2020-01-01T00:00:00.000Z"},
{"name": "stel_dt", "value": "-120"},
{"name": "stel_token", "value": "tok_xyz"},
{"name": "stel_ton_token", "value": "ton_xyz"},
]
with patch(PATCH, _mock_rookiepy(expired_jar)):
with pytest.raises(CookieError, match="expired"):
get_cookies_from_browser("chrome")
# read failure tests # read failure tests
+3 -2
View File
@@ -1,5 +1,6 @@
import json import json
import os import os
from typing import cast
import pytest import pytest
@@ -14,13 +15,13 @@ from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED
@pytest.fixture @pytest.fixture
def cookies(): def cookies() -> dict[str, str]:
"""Load Fragment cookies from COOKIES_JSON env var; skip if unavailable.""" """Load Fragment cookies from COOKIES_JSON env var; skip if unavailable."""
raw = os.environ.get("COOKIES_JSON") raw = os.environ.get("COOKIES_JSON")
if not raw: if not raw:
pytest.skip("COOKIES_JSON env var not set") pytest.skip("COOKIES_JSON env var not set")
try: try:
return json.loads(raw) return cast(dict[str, str], json.loads(raw))
except Exception as exc: except Exception as exc:
pytest.skip(f"Cookies unavailable — {exc}") pytest.skip(f"Cookies unavailable — {exc}")