feat: implement CI/CD workflows for testing, linting, and publishing to PyPI

This commit is contained in:
bohd4nx
2026-03-15 21:45:24 +02:00
parent d2d046a2b9
commit 9a090e3dbc
21 changed files with 438 additions and 57 deletions
+101
View File
@@ -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.
+5
View File
@@ -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.
+51
View File
@@ -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.
+34
View File
@@ -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
+31
View File
@@ -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
+49
View File
@@ -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
+7 -4
View File
@@ -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
View File
@@ -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.
+16 -15
View File
@@ -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"))
+15 -11
View File
@@ -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
View File
@@ -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"))
+2
View File
@@ -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",
+3
View File
@@ -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")
+4 -2
View File
@@ -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",
+15
View File
@@ -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)
+33 -2
View File
@@ -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
View File
@@ -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"]
+1
View File
@@ -0,0 +1 @@
-e .[dev]
+1 -2
View File
@@ -1,2 +1 @@
httpx==0.28.1 -e .
tonutils[pytoniq]==2.0.0