mirror of
https://github.com/vibe-existing/pyfragment.git
synced 2026-07-25 06:54:31 +00:00
feat: implement CI/CD workflows for testing, linting, and publishing to PyPI
This commit is contained in:
@@ -0,0 +1,101 @@
|
|||||||
|
name: Bug report
|
||||||
|
description: Report an issue or unexpected behavior in FragmentAPI.
|
||||||
|
labels:
|
||||||
|
- bug
|
||||||
|
body:
|
||||||
|
- type: checkboxes
|
||||||
|
attributes:
|
||||||
|
label: Checklist
|
||||||
|
options:
|
||||||
|
- label: I am sure the error is coming from FragmentAPI code
|
||||||
|
required: true
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: Python version
|
||||||
|
description: Run `python --version` inside your virtualenv
|
||||||
|
placeholder: e.g. 3.12.3
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: FragmentAPI version
|
||||||
|
description: Run `pip show fragmentapi` inside your virtualenv
|
||||||
|
placeholder: e.g. 2026.1.0
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Expected behavior
|
||||||
|
description: Describe what you expected to happen.
|
||||||
|
placeholder: e.g. Stars should be gifted and StarsResult returned.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Current behavior
|
||||||
|
description: Describe what is actually happening.
|
||||||
|
placeholder: e.g. RequestError is raised with status 400.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Steps to reproduce
|
||||||
|
description: Minimal steps that reproduce the issue.
|
||||||
|
placeholder: |
|
||||||
|
1. Create FragmentClient with valid credentials
|
||||||
|
2. Call gift_stars("@username", amount=100)
|
||||||
|
3. See error
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Code example
|
||||||
|
description: Provide a [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) if applicable.
|
||||||
|
placeholder: |
|
||||||
|
import asyncio
|
||||||
|
from fragmentapi import FragmentClient
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
client = FragmentClient(...)
|
||||||
|
result = await client.gift_stars("@username", amount=100)
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
render: python
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
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
|
||||||
|
...
|
||||||
|
fragmentapi.types.RequestError: ...
|
||||||
|
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.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
blank_issues_enabled: true
|
||||||
|
contact_links:
|
||||||
|
- name: Ask a question or start a discussion
|
||||||
|
url: https://github.com/bohd4nx/FragmentAPI/discussions
|
||||||
|
about: General questions, ideas, and community help go here — not in the issue tracker.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
name: Feature request
|
||||||
|
description: Suggest an improvement or new feature for FragmentAPI.
|
||||||
|
labels:
|
||||||
|
- enhancement
|
||||||
|
body:
|
||||||
|
- type: dropdown
|
||||||
|
attributes:
|
||||||
|
label: FragmentAPI version
|
||||||
|
description: Which version are you running?
|
||||||
|
options:
|
||||||
|
- latest
|
||||||
|
- older
|
||||||
|
- n/a
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Problem
|
||||||
|
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.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Proposed solution
|
||||||
|
description: Describe what you would like to see added or changed.
|
||||||
|
placeholder: e.g. Add a get_balance() method to FragmentClient.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Alternatives considered
|
||||||
|
description: Any workarounds or alternative approaches you have thought of.
|
||||||
|
placeholder: e.g. I manually call the Fragment API, but it's not ergonomic.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Code example
|
||||||
|
description: A short example demonstrating the desired API, if applicable.
|
||||||
|
placeholder: |
|
||||||
|
balance = await client.get_balance()
|
||||||
|
print(balance.ton)
|
||||||
|
render: python
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Additional information
|
||||||
|
description: Any other context, screenshots, or references.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Description
|
||||||
|
|
||||||
|
Please include a summary of the change and which issue is fixed.
|
||||||
|
Include relevant motivation and context.
|
||||||
|
|
||||||
|
Fixes # (issue)
|
||||||
|
|
||||||
|
## Type of change
|
||||||
|
|
||||||
|
- [ ] Documentation (typos, examples, or any docs update)
|
||||||
|
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||||
|
- [ ] New feature (non-breaking change which adds functionality)
|
||||||
|
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
|
||||||
|
- [ ] This change requires a documentation update
|
||||||
|
|
||||||
|
## How has this been tested?
|
||||||
|
|
||||||
|
Describe the tests you ran to verify the change and list any relevant details.
|
||||||
|
|
||||||
|
- [ ] Existing tests pass (`pytest`)
|
||||||
|
- [ ] New tests added for this change
|
||||||
|
|
||||||
|
**Test configuration:**
|
||||||
|
* OS:
|
||||||
|
* Python version:
|
||||||
|
* FragmentAPI version:
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] My code follows the style guidelines of this project
|
||||||
|
- [ ] I have performed a self-review of my own code
|
||||||
|
- [ ] I have updated documentation where necessary
|
||||||
|
- [ ] I have added tests that prove my fix or feature works
|
||||||
|
- [ ] All new and existing tests pass locally
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
name: Lint
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
pull_request:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
name: Lint & format
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6.0.2
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v6.2.0
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v7.5.0
|
||||||
|
|
||||||
|
- name: Install dev dependencies
|
||||||
|
run: uv pip install --system ".[dev]"
|
||||||
|
|
||||||
|
- name: Run ruff
|
||||||
|
run: ruff check .
|
||||||
|
|
||||||
|
- name: Run black
|
||||||
|
run: black --check . --target-version py312
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
name: Publish to PyPI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6.0.2
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v6.2.0
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v7.5.0
|
||||||
|
|
||||||
|
- name: Build distribution
|
||||||
|
run: uv build
|
||||||
|
|
||||||
|
- name: Upload artifacts
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: dist
|
||||||
|
path: dist/*
|
||||||
|
|
||||||
|
publish:
|
||||||
|
name: Publish
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment:
|
||||||
|
name: pypi
|
||||||
|
url: https://pypi.org/project/fragmentapi/
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download artifacts
|
||||||
|
uses: actions/download-artifact@v8.0.1
|
||||||
|
with:
|
||||||
|
name: dist
|
||||||
|
path: dist
|
||||||
|
|
||||||
|
- name: Publish to PyPI
|
||||||
|
uses: pypa/gh-action-pypi-publish@v1.13.0
|
||||||
@@ -8,18 +8,21 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
|
name: Run tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6.0.2
|
||||||
|
|
||||||
- uses: actions/setup-python@v6
|
- uses: actions/setup-python@v6.2.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
cache: "pip"
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v7.5.0
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pip install -r requirements.txt pytest pytest-asyncio
|
run: uv pip install --system ".[dev]"
|
||||||
|
|
||||||
- name: Write cookies.json
|
- name: Write cookies.json
|
||||||
if: ${{ env.COOKIES_JSON != '' }}
|
if: ${{ env.COOKIES_JSON != '' }}
|
||||||
|
|||||||
+13
@@ -24,3 +24,16 @@ logs/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
cookies.json
|
cookies.json
|
||||||
|
|
||||||
|
# Testing & tooling artifacts
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# Build & distribution
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
|||||||
Binary file not shown.
@@ -17,9 +17,9 @@ from fragmentapi.types import (
|
|||||||
)
|
)
|
||||||
from fragmentapi.utils import (
|
from fragmentapi.utils import (
|
||||||
execute_transaction_request,
|
execute_transaction_request,
|
||||||
|
fragment_post,
|
||||||
get_account_info,
|
get_account_info,
|
||||||
get_fragment_hash,
|
get_fragment_hash,
|
||||||
parse_json_response,
|
|
||||||
process_transaction,
|
process_transaction,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -40,16 +40,16 @@ async def _search_recipient(
|
|||||||
username: str,
|
username: str,
|
||||||
months: int,
|
months: int,
|
||||||
) -> str:
|
) -> str:
|
||||||
resp = await session.post(
|
result = await fragment_post(
|
||||||
f"https://fragment.com/api?hash={fragment_hash}",
|
session,
|
||||||
headers=HEADERS,
|
fragment_hash,
|
||||||
data={
|
HEADERS,
|
||||||
|
{
|
||||||
"query": username,
|
"query": username,
|
||||||
"months": months,
|
"months": months,
|
||||||
"method": "searchPremiumGiftRecipient",
|
"method": "searchPremiumGiftRecipient",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
result = parse_json_response(resp, "searchPremiumGiftRecipient")
|
|
||||||
recipient = result.get("found", {}).get("recipient")
|
recipient = result.get("found", {}).get("recipient")
|
||||||
if not recipient:
|
if not recipient:
|
||||||
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
|
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
|
||||||
@@ -62,26 +62,27 @@ async def _init_request(
|
|||||||
recipient: str,
|
recipient: str,
|
||||||
months: int,
|
months: int,
|
||||||
) -> str:
|
) -> str:
|
||||||
await session.post(
|
await fragment_post(
|
||||||
f"https://fragment.com/api?hash={fragment_hash}",
|
session,
|
||||||
headers=HEADERS,
|
fragment_hash,
|
||||||
data={
|
HEADERS,
|
||||||
|
{
|
||||||
"mode": "new",
|
"mode": "new",
|
||||||
"lv": "false",
|
"lv": "false",
|
||||||
"dh": str(int(time.time())),
|
"dh": str(int(time.time())),
|
||||||
"method": "updatePremiumState",
|
"method": "updatePremiumState",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
resp = await session.post(
|
result = await fragment_post(
|
||||||
f"https://fragment.com/api?hash={fragment_hash}",
|
session,
|
||||||
headers=HEADERS,
|
fragment_hash,
|
||||||
data={
|
HEADERS,
|
||||||
|
{
|
||||||
"recipient": recipient,
|
"recipient": recipient,
|
||||||
"months": months,
|
"months": months,
|
||||||
"method": "initGiftPremiumRequest",
|
"method": "initGiftPremiumRequest",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
result = parse_json_response(resp, "initGiftPremiumRequest")
|
|
||||||
req_id = result.get("req_id")
|
req_id = result.get("req_id")
|
||||||
if not req_id:
|
if not req_id:
|
||||||
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium purchase"))
|
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium purchase"))
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ from fragmentapi.types import (
|
|||||||
)
|
)
|
||||||
from fragmentapi.utils import (
|
from fragmentapi.utils import (
|
||||||
execute_transaction_request,
|
execute_transaction_request,
|
||||||
|
fragment_post,
|
||||||
get_account_info,
|
get_account_info,
|
||||||
get_fragment_hash,
|
get_fragment_hash,
|
||||||
parse_json_response,
|
|
||||||
process_transaction,
|
process_transaction,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,12 +38,16 @@ async def _search_recipient(
|
|||||||
fragment_hash: str,
|
fragment_hash: str,
|
||||||
username: str,
|
username: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
resp = await session.post(
|
result = await fragment_post(
|
||||||
f"https://fragment.com/api?hash={fragment_hash}",
|
session,
|
||||||
headers=HEADERS,
|
fragment_hash,
|
||||||
data={"query": username, "quantity": "", "method": "searchStarsRecipient"},
|
HEADERS,
|
||||||
|
{
|
||||||
|
"query": username,
|
||||||
|
"quantity": "",
|
||||||
|
"method": "searchStarsRecipient",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
result = parse_json_response(resp, "searchStarsRecipient")
|
|
||||||
recipient = result.get("found", {}).get("recipient")
|
recipient = result.get("found", {}).get("recipient")
|
||||||
if not recipient:
|
if not recipient:
|
||||||
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
|
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
|
||||||
@@ -56,16 +60,16 @@ async def _init_request(
|
|||||||
recipient: str,
|
recipient: str,
|
||||||
amount: int,
|
amount: int,
|
||||||
) -> str:
|
) -> str:
|
||||||
resp = await session.post(
|
result = await fragment_post(
|
||||||
f"https://fragment.com/api?hash={fragment_hash}",
|
session,
|
||||||
headers=HEADERS,
|
fragment_hash,
|
||||||
data={
|
HEADERS,
|
||||||
|
{
|
||||||
"recipient": recipient,
|
"recipient": recipient,
|
||||||
"quantity": amount,
|
"quantity": amount,
|
||||||
"method": "initBuyStarsRequest",
|
"method": "initBuyStarsRequest",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
result = parse_json_response(resp, "initBuyStarsRequest")
|
|
||||||
req_id = result.get("req_id")
|
req_id = result.get("req_id")
|
||||||
if not req_id:
|
if not req_id:
|
||||||
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars purchase"))
|
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars purchase"))
|
||||||
|
|||||||
+15
-16
@@ -16,9 +16,9 @@ from fragmentapi.types import (
|
|||||||
)
|
)
|
||||||
from fragmentapi.utils import (
|
from fragmentapi.utils import (
|
||||||
execute_transaction_request,
|
execute_transaction_request,
|
||||||
|
fragment_post,
|
||||||
get_account_info,
|
get_account_info,
|
||||||
get_fragment_hash,
|
get_fragment_hash,
|
||||||
parse_json_response,
|
|
||||||
process_transaction,
|
process_transaction,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,17 +38,16 @@ async def _search_recipient(
|
|||||||
fragment_hash: str,
|
fragment_hash: str,
|
||||||
username: str,
|
username: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
await session.post(
|
await fragment_post(session, fragment_hash, HEADERS, {"mode": "new", "method": "updateAdsTopupState"})
|
||||||
f"https://fragment.com/api?hash={fragment_hash}",
|
result = await fragment_post(
|
||||||
headers=HEADERS,
|
session,
|
||||||
data={"mode": "new", "method": "updateAdsTopupState"},
|
fragment_hash,
|
||||||
|
HEADERS,
|
||||||
|
{
|
||||||
|
"query": username,
|
||||||
|
"method": "searchAdsTopupRecipient",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
resp = await session.post(
|
|
||||||
f"https://fragment.com/api?hash={fragment_hash}",
|
|
||||||
headers=HEADERS,
|
|
||||||
data={"query": username, "method": "searchAdsTopupRecipient"},
|
|
||||||
)
|
|
||||||
result = parse_json_response(resp, "searchAdsTopupRecipient")
|
|
||||||
recipient = result.get("found", {}).get("recipient")
|
recipient = result.get("found", {}).get("recipient")
|
||||||
if not recipient:
|
if not recipient:
|
||||||
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
|
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
|
||||||
@@ -61,16 +60,16 @@ async def _init_request(
|
|||||||
recipient: str,
|
recipient: str,
|
||||||
amount: int,
|
amount: int,
|
||||||
) -> str:
|
) -> str:
|
||||||
resp = await session.post(
|
result = await fragment_post(
|
||||||
f"https://fragment.com/api?hash={fragment_hash}",
|
session,
|
||||||
headers=HEADERS,
|
fragment_hash,
|
||||||
data={
|
HEADERS,
|
||||||
|
{
|
||||||
"recipient": recipient,
|
"recipient": recipient,
|
||||||
"amount": amount,
|
"amount": amount,
|
||||||
"method": "initAdsTopupRequest",
|
"method": "initAdsTopupRequest",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
result = parse_json_response(resp, "initAdsTopupRequest")
|
|
||||||
req_id = result.get("req_id")
|
req_id = result.get("req_id")
|
||||||
if not req_id:
|
if not req_id:
|
||||||
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="TON topup"))
|
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="TON topup"))
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from fragmentapi.types.constants import (
|
from fragmentapi.types.constants import (
|
||||||
BASE_HEADERS,
|
BASE_HEADERS,
|
||||||
DEVICE,
|
DEVICE,
|
||||||
|
MIN_TON_BALANCE,
|
||||||
PREMIUM_PAGE,
|
PREMIUM_PAGE,
|
||||||
REQUIRED_COOKIE_KEYS,
|
REQUIRED_COOKIE_KEYS,
|
||||||
STARS_PAGE,
|
STARS_PAGE,
|
||||||
@@ -30,6 +31,7 @@ __all__ = [
|
|||||||
# constants
|
# constants
|
||||||
"BASE_HEADERS",
|
"BASE_HEADERS",
|
||||||
"DEVICE",
|
"DEVICE",
|
||||||
|
"MIN_TON_BALANCE",
|
||||||
"PREMIUM_PAGE",
|
"PREMIUM_PAGE",
|
||||||
"REQUIRED_COOKIE_KEYS",
|
"REQUIRED_COOKIE_KEYS",
|
||||||
"STARS_PAGE",
|
"STARS_PAGE",
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ SUPPORTED_WALLET_VERSIONS: frozenset[str] = frozenset(get_args(WalletVersion))
|
|||||||
# Wallet class map — used to resolve the correct contract from WALLET_VERSION
|
# Wallet class map — used to resolve the correct contract from WALLET_VERSION
|
||||||
WALLET_CLASSES: dict[str, type] = {"V4R2": WalletV4R2, "V5R1": WalletV5R1}
|
WALLET_CLASSES: dict[str, type] = {"V4R2": WalletV4R2, "V5R1": WalletV5R1}
|
||||||
|
|
||||||
|
# Minimum wallet balance required to cover TON network gas fees.
|
||||||
|
MIN_TON_BALANCE: float = 0.056
|
||||||
|
|
||||||
# Required Fragment session cookie keys
|
# Required Fragment session cookie keys
|
||||||
REQUIRED_COOKIE_KEYS: tuple[str, ...] = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token")
|
REQUIRED_COOKIE_KEYS: tuple[str, ...] = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token")
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
from fragmentapi.utils.client import (
|
from fragmentapi.utils.decoder import clean_decode
|
||||||
|
from fragmentapi.utils.http import (
|
||||||
execute_transaction_request,
|
execute_transaction_request,
|
||||||
|
fragment_post,
|
||||||
get_fragment_hash,
|
get_fragment_hash,
|
||||||
parse_json_response,
|
parse_json_response,
|
||||||
)
|
)
|
||||||
from fragmentapi.utils.decoder import clean_decode
|
|
||||||
from fragmentapi.utils.wallet import get_account_info, process_transaction
|
from fragmentapi.utils.wallet import get_account_info, process_transaction
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"clean_decode",
|
"clean_decode",
|
||||||
"execute_transaction_request",
|
"execute_transaction_request",
|
||||||
|
"fragment_post",
|
||||||
"get_account_info",
|
"get_account_info",
|
||||||
"get_fragment_hash",
|
"get_fragment_hash",
|
||||||
"parse_json_response",
|
"parse_json_response",
|
||||||
|
|||||||
@@ -6,6 +6,21 @@ from fragmentapi.types import RequestError
|
|||||||
|
|
||||||
|
|
||||||
def clean_decode(payload: str) -> str:
|
def clean_decode(payload: str) -> str:
|
||||||
|
"""Decode a base64-encoded BOC payload to a plain-text comment string.
|
||||||
|
|
||||||
|
Fragment transaction payloads are BOC-serialised TVM cells. This function
|
||||||
|
base64-decodes the payload, parses the cell, skips the 32-bit op-code
|
||||||
|
prefix, and reads the snake-encoded UTF-8 comment.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payload: Base64url-encoded BOC string (padding is added automatically).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decoded comment string, or ``""`` for an empty payload.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RequestError: If the payload cannot be decoded or parsed.
|
||||||
|
"""
|
||||||
s = payload.strip()
|
s = payload.strip()
|
||||||
if not s:
|
if not s:
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -76,6 +76,35 @@ def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any
|
|||||||
raise RequestError(RequestError.UNPARSEABLE.format(context=context, exc=exc)) from exc
|
raise RequestError(RequestError.UNPARSEABLE.format(context=context, exc=exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
async def fragment_post(
|
||||||
|
session: httpx.AsyncClient,
|
||||||
|
fragment_hash: str,
|
||||||
|
headers: dict[str, str],
|
||||||
|
data: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""POST a single request to the Fragment API.
|
||||||
|
|
||||||
|
Builds the ``/api?hash=`` URL, sends the request, and returns the
|
||||||
|
parsed JSON body. Use this for every API method call — search,
|
||||||
|
init, state updates, etc.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Active httpx session with Fragment cookies.
|
||||||
|
fragment_hash: Short-lived hash from the Fragment page HTML.
|
||||||
|
headers: Page-specific HTTP headers.
|
||||||
|
data: Form data payload; must include a ``"method"`` key.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed API response as a dict.
|
||||||
|
"""
|
||||||
|
resp = await session.post(
|
||||||
|
f"https://fragment.com/api?hash={fragment_hash}",
|
||||||
|
headers=headers,
|
||||||
|
data=data,
|
||||||
|
)
|
||||||
|
return parse_json_response(resp, data.get("method", "request"))
|
||||||
|
|
||||||
|
|
||||||
async def execute_transaction_request(
|
async def execute_transaction_request(
|
||||||
session: httpx.AsyncClient,
|
session: httpx.AsyncClient,
|
||||||
headers: dict,
|
headers: dict,
|
||||||
@@ -97,9 +126,7 @@ async def execute_transaction_request(
|
|||||||
VerificationError: If Fragment requires KYC verification.
|
VerificationError: If Fragment requires KYC verification.
|
||||||
RequestError: If the response cannot be parsed.
|
RequestError: If the response cannot be parsed.
|
||||||
"""
|
"""
|
||||||
url = f"https://fragment.com/api?hash={fragment_hash}"
|
transaction = await fragment_post(session, fragment_hash, headers, tx_data)
|
||||||
resp = await session.post(url, headers=headers, data=tx_data)
|
|
||||||
transaction = parse_json_response(resp, tx_data.get("method", "transaction"))
|
|
||||||
|
|
||||||
if transaction.get("need_verify"):
|
if transaction.get("need_verify"):
|
||||||
raise VerificationError(VerificationError.KYC_REQUIRED)
|
raise VerificationError(VerificationError.KYC_REQUIRED)
|
||||||
@@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Any
|
|||||||
from tonutils.clients import TonapiClient
|
from tonutils.clients import TonapiClient
|
||||||
from tonutils.types import NetworkGlobalID
|
from tonutils.types import NetworkGlobalID
|
||||||
|
|
||||||
from fragmentapi.types import WALLET_CLASSES, TransactionError, WalletError
|
from fragmentapi.types import MIN_TON_BALANCE, WALLET_CLASSES, TransactionError, WalletError
|
||||||
from fragmentapi.utils.decoder import clean_decode
|
from fragmentapi.utils.decoder import clean_decode
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -16,6 +16,22 @@ def _init_ton_client(client: "FragmentClient") -> TonapiClient:
|
|||||||
|
|
||||||
|
|
||||||
async def process_transaction(client: "FragmentClient", transaction_data: dict) -> str:
|
async def process_transaction(client: "FragmentClient", transaction_data: dict) -> str:
|
||||||
|
"""Sign and broadcast a Fragment transaction to the TON network.
|
||||||
|
|
||||||
|
Validates the payload structure, checks the wallet balance, decodes the
|
||||||
|
on-chain comment, and calls ``wallet.transfer``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Authenticated :class:`FragmentClient` instance.
|
||||||
|
transaction_data: Raw transaction dict from ``execute_transaction_request``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Normalised transaction hash string.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TransactionError: If the payload is malformed or the broadcast fails.
|
||||||
|
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 "messages" not in transaction_data["transaction"]:
|
||||||
raise TransactionError(TransactionError.INVALID_PAYLOAD)
|
raise TransactionError(TransactionError.INVALID_PAYLOAD)
|
||||||
|
|
||||||
@@ -30,7 +46,7 @@ async def process_transaction(client: "FragmentClient", transaction_data: dict)
|
|||||||
try:
|
try:
|
||||||
await wallet.refresh()
|
await wallet.refresh()
|
||||||
balance_ton = wallet.balance / 1_000_000_000
|
balance_ton = wallet.balance / 1_000_000_000
|
||||||
if balance_ton < 0.056:
|
if balance_ton < MIN_TON_BALANCE:
|
||||||
raise WalletError(WalletError.LOW_BALANCE.format(balance=balance_ton))
|
raise WalletError(WalletError.LOW_BALANCE.format(balance=balance_ton))
|
||||||
except WalletError:
|
except WalletError:
|
||||||
raise
|
raise
|
||||||
@@ -54,6 +70,21 @@ async def process_transaction(client: "FragmentClient", transaction_data: dict)
|
|||||||
|
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
Fragment requires account info to build each transaction payload. The
|
||||||
|
returned dict is JSON-serialised and passed as the ``account`` field in
|
||||||
|
``getBuy*Link`` / ``get*Link`` requests.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Authenticated :class:`FragmentClient` instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with ``address``, ``publicKey``, ``chain``, ``walletStateInit``.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
WalletError: If account info cannot be retrieved.
|
||||||
|
"""
|
||||||
async with _init_ton_client(client) as ton:
|
async with _init_ton_client(client) as ton:
|
||||||
try:
|
try:
|
||||||
wallet_cls = WALLET_CLASSES[client.wallet_version]
|
wallet_cls = WALLET_CLASSES[client.wallet_version]
|
||||||
|
|||||||
+12
-2
@@ -30,6 +30,14 @@ dependencies = [
|
|||||||
"tonutils[pytoniq]==2.0.0",
|
"tonutils[pytoniq]==2.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=9.0",
|
||||||
|
"pytest-asyncio>=1.0",
|
||||||
|
"ruff",
|
||||||
|
"black",
|
||||||
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://github.com/bohd4nx/FragmentAPI"
|
Homepage = "https://github.com/bohd4nx/FragmentAPI"
|
||||||
Repository = "https://github.com/bohd4nx/FragmentAPI"
|
Repository = "https://github.com/bohd4nx/FragmentAPI"
|
||||||
@@ -49,11 +57,13 @@ line-length = 128
|
|||||||
target-version = ["py312"]
|
target-version = ["py312"]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
target-version = "py312"
|
|
||||||
line-length = 128
|
line-length = 128
|
||||||
|
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
|
||||||
select = ["E", "F", "W", "I"]
|
select = ["E", "F", "W", "I"]
|
||||||
# E501 — line too long (covered by line-length above)
|
|
||||||
ignore = ["E501"]
|
ignore = ["E501"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"tests/*" = ["E402"]
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
-e .[dev]
|
||||||
+1
-2
@@ -1,2 +1 @@
|
|||||||
httpx==0.28.1
|
-e .
|
||||||
tonutils[pytoniq]==2.0.0
|
|
||||||
|
|||||||
Reference in New Issue
Block a user