From 333b6f45cee34cd4185e193eb92aa7d5409472a3 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Tue, 14 Apr 2026 02:01:16 +0300 Subject: [PATCH 1/7] fix: update Miniconda setup action version and adjust activation settings --- .github/workflows/ci.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cfbc581..419536f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,12 +39,13 @@ jobs: - uses: actions/checkout@v6.0.2 - name: Set up conda (Miniconda) - uses: conda-incubator/setup-miniconda@v3 + uses: conda-incubator/setup-miniconda@v3.2.0 with: auto-update-conda: true python-version: ${{ matrix.python-version }} activate-environment: pyfragment-test - auto-activate-base: false + auto-activate: true + conda-remove-defaults: true - name: Install package and dev dependencies shell: bash -el {0} From d66602b6466ba7285de63e37a759060b3a8061de Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Tue, 14 Apr 2026 02:03:22 +0300 Subject: [PATCH 2/7] fix: replace Miniconda setup with Python setup in CI workflow for consistency --- .github/workflows/ci.yml | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 419536f..d0eb952 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,19 +38,12 @@ jobs: steps: - uses: actions/checkout@v6.0.2 - - name: Set up conda (Miniconda) - uses: conda-incubator/setup-miniconda@v3.2.0 + - uses: actions/setup-python@v6.2.0 with: - auto-update-conda: true python-version: ${{ matrix.python-version }} - activate-environment: pyfragment-test - auto-activate: true - conda-remove-defaults: true - name: Install package and dev dependencies - shell: bash -el {0} run: pip install ".[dev]" - name: Run tests - shell: bash -el {0} run: pytest From fca60135a626db156a1e324955ba1b1759e2d7ca Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Sat, 18 Apr 2026 21:02:53 +0300 Subject: [PATCH 3/7] refactor: update type hints for better clarity and consistency across the codebase --- .github/ISSUE_TEMPLATE/bug.yaml | 172 ++++++++++++------------- .github/ISSUE_TEMPLATE/config.yml | 6 +- .github/ISSUE_TEMPLATE/feature.yaml | 84 ++++++------ .github/dependabot.yml | 32 ++--- .github/workflows/ci.yml | 34 ++--- .github/workflows/publish.yml | 114 ++++++++-------- pyfragment/client.py | 8 +- pyfragment/methods/anonymous_number.py | 6 +- pyfragment/methods/giveaway_premium.py | 2 +- pyfragment/methods/giveaway_stars.py | 2 +- pyfragment/methods/purchase_premium.py | 2 +- pyfragment/methods/purchase_stars.py | 2 +- pyfragment/methods/recharge_ads.py | 2 +- pyfragment/methods/search_gifts.py | 2 +- pyfragment/methods/search_numbers.py | 2 +- pyfragment/methods/search_usernames.py | 2 +- pyfragment/methods/topup_ton.py | 2 +- pyfragment/types/exceptions.py | 8 +- pyfragment/utils/cookies.py | 6 +- pyfragment/utils/http.py | 4 +- pyfragment/utils/wallet.py | 8 +- pyproject.toml | 26 ++-- tests/003_test_balance.py | 3 +- tests/014_test_cookies.py | 2 +- tests/conftest.py | 5 +- 25 files changed, 270 insertions(+), 266 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug.yaml b/.github/ISSUE_TEMPLATE/bug.yaml index 8006d94..100fec3 100644 --- a/.github/ISSUE_TEMPLATE/bug.yaml +++ b/.github/ISSUE_TEMPLATE/bug.yaml @@ -1,101 +1,101 @@ name: Bug report description: Report an issue or unexpected behavior in pyfragment. labels: - - bug +- bug body: - - type: checkboxes - attributes: - label: Checklist - options: - - label: I am sure the error is coming from pyfragment 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: +- type: checkboxes + attributes: + label: Checklist + options: + - label: I am sure the error is coming from pyfragment code + required: true + - label: I have searched the issue tracker for similar bug reports, including closed ones 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: markdown + attributes: + value: | + ## Context + Please provide as much detail as possible to help us reproduce and fix the issue. - - type: input - attributes: - label: pyfragment version - description: Run `pip show pyfragment` inside your virtualenv - placeholder: e.g. 2026.1.0 - validations: - required: true +- type: input + attributes: + label: Operating system + placeholder: e.g. Ubuntu 22.04 / macOS 14 / Windows 11 + validations: + required: true - - type: textarea - attributes: - label: Expected behavior - description: Describe what you expected to happen. - placeholder: e.g. Stars should be purchased and StarsResult returned. - 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: textarea - attributes: - label: Current behavior - description: Describe what is actually happening. - placeholder: e.g. ParseError is raised with status 400. - validations: - required: true +- type: input + attributes: + label: pyfragment version + description: Run `pip show pyfragment` inside your virtualenv + placeholder: e.g. 2026.1.0 + 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 purchase_stars("@username", amount=100) - 3. See error - validations: - required: true +- type: textarea + attributes: + label: Expected behavior + description: Describe what you expected to happen. + placeholder: e.g. Stars should be purchased and StarsResult returned. + 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 pyfragment import FragmentClient +- type: textarea + attributes: + label: Current behavior + description: Describe what is actually happening. + placeholder: e.g. ParseError is raised with status 400. + validations: + required: true - async def main(): - client = FragmentClient(...) - result = await client.purchase_stars("@username", amount=100) +- type: textarea + attributes: + 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()) - render: python +- 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 pyfragment import FragmentClient - - 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 - ... - pyfragment.types.ParseError: ... - render: sh + async def main(): + client = FragmentClient(...) + result = await client.purchase_stars("@username", amount=100) - - 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. + 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 + ... + 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. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index ea24733..ba43b69 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: true contact_links: - - name: Ask a question or start a discussion - url: https://github.com/bohd4nx/pyfragment/discussions - about: General questions, ideas, and community help go here — not in the issue tracker. +- name: Ask a question or start a discussion + url: https://github.com/bohd4nx/pyfragment/discussions + about: General questions, ideas, and community help go here — not in the issue tracker. diff --git a/.github/ISSUE_TEMPLATE/feature.yaml b/.github/ISSUE_TEMPLATE/feature.yaml index e2a26c4..8ef0b2e 100644 --- a/.github/ISSUE_TEMPLATE/feature.yaml +++ b/.github/ISSUE_TEMPLATE/feature.yaml @@ -1,51 +1,51 @@ name: Feature request description: Suggest an improvement or new feature for pyfragment. labels: - - enhancement +- enhancement body: - - type: dropdown - attributes: - label: pyfragment version - description: Which version are you running? - options: - - latest - - older - - n/a - validations: - required: true +- type: dropdown + attributes: + label: pyfragment 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: 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: 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: 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: 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. +- type: textarea + attributes: + label: Additional information + description: Any other context, screenshots, or references. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6388e89..31f9e0f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,19 +1,19 @@ version: 2 updates: - - package-ecosystem: "pip" - directory: "/" - schedule: - interval: "weekly" - day: "monday" - open-pull-requests-limit: 5 - labels: - - "dependencies" +- package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + labels: + - "dependencies" - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" - day: "monday" - open-pull-requests-limit: 5 - labels: - - "dependencies" +- package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + labels: + - "dependencies" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0eb952..f9ee838 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: ["**"] + branches: [ "**" ] pull_request: - branches: ["**"] + branches: [ "**" ] jobs: lint: @@ -14,15 +14,15 @@ jobs: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: - - uses: actions/checkout@v6.0.2 + - uses: actions/checkout@v6.0.2 - - uses: actions/setup-python@v6.2.0 - with: - python-version: "3.12" + - uses: actions/setup-python@v6.2.0 + with: + python-version: "3.12" - - run: pip install ".[dev]" + - run: pip install ".[dev]" - - run: ruff check . && black --check . --target-version py312 && mypy pyfragment + - run: ruff check . --fix && ruff format . && mypy pyfragment test: name: Tests (Python ${{ matrix.python-version }}) @@ -33,17 +33,17 @@ jobs: strategy: fail-fast: false 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: - - uses: actions/checkout@v6.0.2 + - uses: actions/checkout@v6.0.2 - - uses: actions/setup-python@v6.2.0 - with: - python-version: ${{ matrix.python-version }} + - uses: actions/setup-python@v6.2.0 + with: + python-version: ${{ matrix.python-version }} - - name: Install package and dev dependencies - run: pip install ".[dev]" + - name: Install package and dev dependencies + run: pip install ".[dev]" - - name: Run tests - run: pytest + - name: Run tests + run: pytest diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bc0ece5..1a308c1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -2,9 +2,9 @@ name: Publish on: workflow_run: - workflows: ["CI"] - types: [completed] - branches: [master] + workflows: [ "CI" ] + types: [ completed ] + branches: [ master ] jobs: version-check: @@ -18,24 +18,24 @@ jobs: is-new: ${{ steps.tag.outputs.is-new }} steps: - - uses: actions/checkout@v6.0.2 - with: - fetch-depth: 0 + - uses: actions/checkout@v6.0.2 + with: + fetch-depth: 0 - - name: Read version - id: version - run: | - value=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/') - echo "value=$value" >> $GITHUB_OUTPUT + - name: Read version + id: version + run: | + value=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/') + echo "value=$value" >> $GITHUB_OUTPUT - - name: Check tag - id: tag - run: | - if git ls-remote --tags origin "refs/tags/v${{ steps.version.outputs.value }}" | grep -q .; then - echo "is-new=false" >> $GITHUB_OUTPUT - else - echo "is-new=true" >> $GITHUB_OUTPUT - fi + - name: Check tag + id: tag + run: | + if git ls-remote --tags origin "refs/tags/v${{ steps.version.outputs.value }}" | grep -q .; then + echo "is-new=false" >> $GITHUB_OUTPUT + else + echo "is-new=true" >> $GITHUB_OUTPUT + fi build: name: Build @@ -46,24 +46,24 @@ jobs: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: - - uses: actions/checkout@v6.0.2 + - uses: actions/checkout@v6.0.2 - - uses: actions/setup-python@v6.2.0 - with: - python-version: "3.12" + - uses: actions/setup-python@v6.2.0 + with: + python-version: "3.12" - - uses: astral-sh/setup-uv@v7.6.0 + - uses: astral-sh/setup-uv@v7.6.0 - - run: uv build + - run: uv build - - uses: actions/upload-artifact@v7 - with: - name: dist - path: dist/* + - uses: actions/upload-artifact@v7 + with: + name: dist + path: dist/* publish: name: Publish to PyPI - needs: [version-check, build] + needs: [ version-check, build ] runs-on: ubuntu-latest env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true @@ -74,16 +74,16 @@ jobs: id-token: write steps: - - uses: actions/download-artifact@v8.0.1 - with: - name: dist - path: dist + - uses: actions/download-artifact@v8.0.1 + with: + name: dist + path: dist - - uses: pypa/gh-action-pypi-publish@v1.13.0 + - uses: pypa/gh-action-pypi-publish@v1.13.0 release: name: GitHub Release - needs: [version-check, build] + needs: [ version-check, build ] runs-on: ubuntu-latest env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true @@ -91,27 +91,27 @@ jobs: contents: write steps: - - uses: actions/checkout@v6.0.2 - with: - fetch-depth: 0 + - uses: actions/checkout@v6.0.2 + with: + fetch-depth: 0 - - uses: actions/download-artifact@v8.0.1 - with: - name: dist - path: dist + - uses: actions/download-artifact@v8.0.1 + with: + name: dist + path: dist - - name: Extract latest changelog entry - id: changelog - run: | - body=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md) - echo "body<> $GITHUB_OUTPUT - echo "$body" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT + - name: Extract latest changelog entry + id: changelog + run: | + body=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md) + echo "body<> $GITHUB_OUTPUT + echo "$body" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT - - uses: softprops/action-gh-release@v2.6.1 - with: - tag_name: v${{ needs.version-check.outputs.version }} - name: v${{ needs.version-check.outputs.version }} - files: dist/* - body: ${{ steps.changelog.outputs.body }} - make_latest: true + - uses: softprops/action-gh-release@v2.6.1 + with: + tag_name: v${{ needs.version-check.outputs.version }} + name: v${{ needs.version-check.outputs.version }} + files: dist/* + body: ${{ steps.changelog.outputs.body }} + make_latest: true diff --git a/pyfragment/client.py b/pyfragment/client.py index a085c09..c542d4c 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -77,7 +77,7 @@ class FragmentClient: self, seed: str, api_key: str, - cookies: dict | str, + cookies: dict[str, Any] | str, wallet_version: str = "V5R1", timeout: float = DEFAULT_TIMEOUT, ) -> None: @@ -98,7 +98,7 @@ class FragmentClient: except Exception as exc: raise CookieError(CookieError.READ_FAILED.format(exc=exc)) from exc - missing_keys = [k for k in REQUIRED_COOKIE_KEYS if not str(cast(dict, 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: raise CookieError(CookieError.MISSING_KEYS.format(keys=", ".join(missing_keys))) @@ -112,11 +112,11 @@ class FragmentClient: self.seed: str = seed.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.timeout: float = timeout - async def __aenter__(self) -> "FragmentClient": + async def __aenter__(self) -> FragmentClient: return self async def __aexit__(self, *_: object) -> None: diff --git a/pyfragment/methods/anonymous_number.py b/pyfragment/methods/anonymous_number.py index 590a8e9..fc79d29 100644 --- a/pyfragment/methods/anonymous_number.py +++ b/pyfragment/methods/anonymous_number.py @@ -22,7 +22,7 @@ def _strip_plus(number: str) -> str: 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. Args: @@ -58,7 +58,7 @@ async def get_login_code(client: "FragmentClient", number: str) -> LoginCodeResu 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. 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 -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. This is a two-step operation: Fragment first returns a confirmation hash, diff --git a/pyfragment/methods/giveaway_premium.py b/pyfragment/methods/giveaway_premium.py index 4a6d9b4..2c6675d 100644 --- a/pyfragment/methods/giveaway_premium.py +++ b/pyfragment/methods/giveaway_premium.py @@ -20,7 +20,7 @@ if TYPE_CHECKING: async def giveaway_premium( - client: "FragmentClient", + client: FragmentClient, channel: str, winners: int, months: int = 3, diff --git a/pyfragment/methods/giveaway_stars.py b/pyfragment/methods/giveaway_stars.py index 0788bb5..06646fb 100644 --- a/pyfragment/methods/giveaway_stars.py +++ b/pyfragment/methods/giveaway_stars.py @@ -20,7 +20,7 @@ if TYPE_CHECKING: async def giveaway_stars( - client: "FragmentClient", + client: FragmentClient, channel: str, winners: int, amount: int, diff --git a/pyfragment/methods/purchase_premium.py b/pyfragment/methods/purchase_premium.py index 3130205..771cf8e 100644 --- a/pyfragment/methods/purchase_premium.py +++ b/pyfragment/methods/purchase_premium.py @@ -20,7 +20,7 @@ if TYPE_CHECKING: 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. Args: diff --git a/pyfragment/methods/purchase_stars.py b/pyfragment/methods/purchase_stars.py index cbf3db0..923682a 100644 --- a/pyfragment/methods/purchase_stars.py +++ b/pyfragment/methods/purchase_stars.py @@ -19,7 +19,7 @@ if TYPE_CHECKING: 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. Args: diff --git a/pyfragment/methods/recharge_ads.py b/pyfragment/methods/recharge_ads.py index ffe1fcf..19cf098 100644 --- a/pyfragment/methods/recharge_ads.py +++ b/pyfragment/methods/recharge_ads.py @@ -18,7 +18,7 @@ if TYPE_CHECKING: 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. Args: diff --git a/pyfragment/methods/search_gifts.py b/pyfragment/methods/search_gifts.py index 2623ee7..33fd431 100644 --- a/pyfragment/methods/search_gifts.py +++ b/pyfragment/methods/search_gifts.py @@ -11,7 +11,7 @@ if TYPE_CHECKING: async def search_gifts( - client: "FragmentClient", + client: FragmentClient, query: str = "", collection: str | None = None, sort: str | None = None, diff --git a/pyfragment/methods/search_numbers.py b/pyfragment/methods/search_numbers.py index 83442bb..e82e32e 100644 --- a/pyfragment/methods/search_numbers.py +++ b/pyfragment/methods/search_numbers.py @@ -11,7 +11,7 @@ if TYPE_CHECKING: async def search_numbers( - client: "FragmentClient", + client: FragmentClient, query: str = "", sort: str | None = None, filter: str | None = None, diff --git a/pyfragment/methods/search_usernames.py b/pyfragment/methods/search_usernames.py index 3218ed5..0fd4c39 100644 --- a/pyfragment/methods/search_usernames.py +++ b/pyfragment/methods/search_usernames.py @@ -11,7 +11,7 @@ if TYPE_CHECKING: async def search_usernames( - client: "FragmentClient", + client: FragmentClient, query: str = "", sort: str | None = None, filter: str | None = None, diff --git a/pyfragment/methods/topup_ton.py b/pyfragment/methods/topup_ton.py index 0967ee8..d3ad869 100644 --- a/pyfragment/methods/topup_ton.py +++ b/pyfragment/methods/topup_ton.py @@ -19,7 +19,7 @@ if TYPE_CHECKING: 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. Args: diff --git a/pyfragment/types/exceptions.py b/pyfragment/types/exceptions.py index f2db392..be28af0 100644 --- a/pyfragment/types/exceptions.py +++ b/pyfragment/types/exceptions.py @@ -16,7 +16,7 @@ class ConfigurationError(ClientError): UNSUPPORTED_VERSION = "Unsupported wallet_version '{version}'. Must be one of: {supported}." INVALID_MNEMONIC = "Invalid mnemonic: expected 12, 18, or 24 words, got {count}." 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_STARS_AMOUNT = "Invalid Stars amount: must be an integer between 50 and 1 000 000." @@ -40,7 +40,7 @@ class CookieError(ClientError): ) UNSUPPORTED_BROWSER = "Unsupported browser: '{browser}'. Supported: {supported}." 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 = ( "Fragment cookies not found in {browser}: {keys}. " @@ -74,7 +74,7 @@ class UserNotFoundError(FragmentAPIError): """Raised when the target Telegram user is not found on Fragment.""" 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 +89,7 @@ class TransactionError(FragmentAPIError): """Raised when a TON transaction fails to build or broadcast.""" 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_SSL = ( diff --git a/pyfragment/utils/cookies.py b/pyfragment/utils/cookies.py index 565aa1f..8e24495 100644 --- a/pyfragment/utils/cookies.py +++ b/pyfragment/utils/cookies.py @@ -1,10 +1,12 @@ from __future__ import annotations from datetime import datetime, timezone +from typing import Any 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 @@ -34,7 +36,7 @@ def get_cookies_from_browser(browser: str = "chrome") -> CookieResult: raise CookieError(CookieError.UNSUPPORTED_BROWSER.format(browser=browser, supported=supported)) try: - jar: list[dict] = getattr(rookiepy, key)([FRAGMENT_DOMAIN]) + jar: list[dict[str, Any]] = getattr(rookiepy, key)([FRAGMENT_DOMAIN]) except Exception as exc: raise CookieError(CookieError.BROWSER_READ_FAILED.format(browser=browser, exc=exc, url=FRAGMENT_BASE_URL)) from exc diff --git a/pyfragment/utils/http.py b/pyfragment/utils/http.py index 9cac3fd..a9672d1 100644 --- a/pyfragment/utils/http.py +++ b/pyfragment/utils/http.py @@ -1,7 +1,7 @@ from __future__ import annotations import re -from typing import Any +from typing import Any, cast import httpx @@ -80,7 +80,7 @@ def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any ParseError: If the response body cannot be decoded as JSON. """ try: - return response.json() + return cast(dict[str, Any], response.json()) except Exception as exc: raise ParseError(ParseError.UNPARSEABLE.format(context=context, exc=exc)) from exc diff --git a/pyfragment/utils/wallet.py b/pyfragment/utils/wallet.py index 039f482..49fdec6 100644 --- a/pyfragment/utils/wallet.py +++ b/pyfragment/utils/wallet.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: 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. Validates the payload structure, checks the wallet balance, decodes the @@ -66,7 +66,7 @@ async def process_transaction(client: "FragmentClient", transaction_data: dict[s amount=int(message["amount"]), # nanotons, not TON body=payload, ) - return result.normalized_hash + return str(result.normalized_hash) except ProviderResponseError as exc: if exc.code == 429 and attempt == 0: await asyncio.sleep(1) @@ -91,7 +91,7 @@ async def process_transaction(client: "FragmentClient", transaction_data: dict[s 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. Fragment requires account info to build each transaction payload. The @@ -122,7 +122,7 @@ async def get_account_info(client: "FragmentClient") -> dict[str, Any]: 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. Args: diff --git a/pyproject.toml b/pyproject.toml index 2579e64..98875f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,19 +28,18 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "httpx==0.28.1", - "rookiepy==0.5.6", - "tonutils==2.1.0", + "httpx>=0.25", + "rookiepy>=0.5.6", + "tonutils>=2.0.1", ] [project.optional-dependencies] dev = [ - "pytest==9.0.3", - "pytest-asyncio==1.3.0", + "pytest", + "pytest-asyncio", "pytest-mock", "mypy", "ruff", - "black", ] [project.urls] @@ -58,19 +57,20 @@ python_files = ["[0-9][0-9][0-9]_test_*.py"] asyncio_mode = "auto" addopts = "-v --tb=short" -[tool.black] -line-length = 128 -target-version = ["py312"] - [tool.ruff] line-length = 128 target-version = "py312" [tool.ruff.lint] -# E — pycodestyle errors, F — pyflakes, W — warnings, I — isort -select = ["E", "F", "W", "I"] +# E — pycodestyle errors, F — pyflakes, W — warnings, I — isort, UP — pyupgrade +select = ["E", "F", "W", "I", "UP"] +# E501 — line too long (covered by line-length above) ignore = ["E501"] [tool.ruff.lint.per-file-ignores] -"tests/*" = ["E402"] +"tests/*" = ["E402"] "systests/*" = ["E402"] + +[tool.mypy] +python_version = "3.10" +strict = true diff --git a/tests/003_test_balance.py b/tests/003_test_balance.py index 185ba30..ab95492 100644 --- a/tests/003_test_balance.py +++ b/tests/003_test_balance.py @@ -1,5 +1,6 @@ """Unit tests for process_transaction() — balance validation and broadcast retry logic.""" +from collections.abc import Generator from contextlib import contextmanager from unittest.mock import AsyncMock, MagicMock, patch @@ -45,7 +46,7 @@ def _make_wallet(balance_nanotons: int) -> MagicMock: @contextmanager -def _patch_wallet(wallet: MagicMock): +def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]: with ( patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi, patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes, diff --git a/tests/014_test_cookies.py b/tests/014_test_cookies.py index 27e4c03..6f927b4 100644 --- a/tests/014_test_cookies.py +++ b/tests/014_test_cookies.py @@ -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.chrome.return_value = jar if jar is not None else FAKE_JAR return mock diff --git a/tests/conftest.py b/tests/conftest.py index aa056b1..9524b34 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ import json import os +from typing import cast import pytest @@ -14,13 +15,13 @@ from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED @pytest.fixture -def cookies(): +def cookies() -> dict[str, str]: """Load Fragment cookies from COOKIES_JSON env var; skip if unavailable.""" raw = os.environ.get("COOKIES_JSON") if not raw: pytest.skip("COOKIES_JSON env var not set") try: - return json.loads(raw) + return cast(dict[str, str], json.loads(raw)) except Exception as exc: pytest.skip(f"Cookies unavailable — {exc}") From 8f3e9f4bd80442d0fb8cb189dd1e7d431ce9fe80 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Sat, 18 Apr 2026 21:05:12 +0300 Subject: [PATCH 4/7] chore: update action versions in CI workflow and adjust linter ignores for compatibility --- .github/workflows/publish.yml | 6 +++--- pyproject.toml | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1a308c1..602a958 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -52,7 +52,7 @@ jobs: with: python-version: "3.12" - - uses: astral-sh/setup-uv@v7.6.0 + - uses: astral-sh/setup-uv@v8.0.0 - run: uv build @@ -79,7 +79,7 @@ jobs: name: dist path: dist - - uses: pypa/gh-action-pypi-publish@v1.13.0 + - uses: pypa/gh-action-pypi-publish@v1.14.0 release: name: GitHub Release @@ -108,7 +108,7 @@ jobs: echo "$body" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT - - uses: softprops/action-gh-release@v2.6.1 + - uses: softprops/action-gh-release@v3.0.0 with: tag_name: v${{ needs.version-check.outputs.version }} name: v${{ needs.version-check.outputs.version }} diff --git a/pyproject.toml b/pyproject.toml index 98875f2..a70d674 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,7 +65,8 @@ target-version = "py312" # E — pycodestyle errors, F — pyflakes, W — warnings, I — isort, UP — pyupgrade select = ["E", "F", "W", "I", "UP"] # E501 — line too long (covered by line-length above) -ignore = ["E501"] +# UP017 — use datetime.UTC (only available in Python 3.11+, we support 3.10) +ignore = ["E501", "UP017"] [tool.ruff.lint.per-file-ignores] "tests/*" = ["E402"] From 8c7423a6ab9237a966dcd509722ff99749edd823 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Sat, 18 Apr 2026 21:11:14 +0300 Subject: [PATCH 5/7] fix: update Python version in CI workflow and refine project metadata for clarity --- .github/workflows/ci.yml | 4 ++-- pyproject.toml | 48 ++++++++++++++++++++++++++-------------- 2 files changed, 33 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9ee838..9572560 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,11 +18,11 @@ jobs: - uses: actions/setup-python@v6.2.0 with: - python-version: "3.12" + python-version: "3.10" - run: pip install ".[dev]" - - run: ruff check . --fix && ruff format . && mypy pyfragment + - run: ruff check . && ruff format --check . && mypy pyfragment test: name: Tests (Python ${{ matrix.python-version }}) diff --git a/pyproject.toml b/pyproject.toml index a70d674..32a72c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,38 +9,51 @@ description = "Async Python client for the Fragment API — a unified toolkit to readme = "README.md" license = { text = "MIT" } requires-python = ">=3.10" -authors = [{ name = "bohd4nx", url = "https://github.com/bohd4nx" }] -keywords = ["fragment", "telegram", "ton", "stars", "premium", "crypto", "blockchain"] +authors = [{ name = "bohd4nx" }] +keywords = [ + "fragment", + "fragment-api", + "telegram", + "telegram-stars", + "telegram-premium", + "ton", + "tonkeeper", + "tonapi", + "crypto", + "blockchain", + "web3", + "giveaway", + "anonymous-number", + "username", + "nft", + "async", + "asyncio", +] classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", + "Intended Audience :: Financial and Insurance Industry", "License :: OSI Approved :: MIT License", "Natural Language :: English", "Operating System :: OS Independent", + "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Framework :: AsyncIO", + "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Internet", + "Topic :: Internet :: WWW/HTTP", "Topic :: Office/Business :: Financial", + "Topic :: Office/Business :: Financial :: Investment", "Typing :: Typed", ] -dependencies = [ - "httpx>=0.25", - "rookiepy>=0.5.6", - "tonutils>=2.0.1", -] +dependencies = ["httpx>=0.25", "rookiepy>=0.5.6", "tonutils>=2.0.1"] [project.optional-dependencies] -dev = [ - "pytest", - "pytest-asyncio", - "pytest-mock", - "mypy", - "ruff", -] +dev = ["pytest", "pytest-asyncio", "pytest-mock", "mypy", "ruff"] [project.urls] Homepage = "https://github.com/bohd4nx/pyfragment" @@ -52,10 +65,10 @@ Changelog = "https://github.com/bohd4nx/pyfragment/blob/master/CHANGELOG.md" packages = ["pyfragment"] [tool.pytest.ini_options] -testpaths = ["tests"] +testpaths = ["tests"] python_files = ["[0-9][0-9][0-9]_test_*.py"] asyncio_mode = "auto" -addopts = "-v --tb=short" +addopts = "-v --tb=short" [tool.ruff] line-length = 128 @@ -69,9 +82,10 @@ select = ["E", "F", "W", "I", "UP"] ignore = ["E501", "UP017"] [tool.ruff.lint.per-file-ignores] -"tests/*" = ["E402"] +"tests/*" = ["E402"] "systests/*" = ["E402"] [tool.mypy] python_version = "3.10" strict = true +exclude = ["^systests/", "^examples/"] From 7923dff8b20a84bce719dd9872f187a8fa58a61d Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Tue, 21 Apr 2026 20:06:11 +0300 Subject: [PATCH 6/7] fix: remove unnecessary 'method' key from API request data and improve error handling for expired cookies and non-200 HTTP responses --- pyfragment/methods/search_gifts.py | 2 +- pyfragment/methods/search_numbers.py | 2 +- pyfragment/methods/search_usernames.py | 2 +- pyfragment/types/exceptions.py | 1 + pyfragment/utils/cookies.py | 5 +++++ pyfragment/utils/http.py | 2 ++ pyfragment/utils/wallet.py | 2 +- tests/003_test_balance.py | 6 ++++++ tests/008_test_call.py | 21 ++++++++++++++++++++- tests/014_test_cookies.py | 15 +++++++++++++++ 10 files changed, 53 insertions(+), 5 deletions(-) diff --git a/pyfragment/methods/search_gifts.py b/pyfragment/methods/search_gifts.py index 33fd431..badea9e 100644 --- a/pyfragment/methods/search_gifts.py +++ b/pyfragment/methods/search_gifts.py @@ -45,7 +45,7 @@ async def search_gifts( FragmentAPIError: If the Fragment API returns an error. 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: data["collection"] = collection if sort is not None: diff --git a/pyfragment/methods/search_numbers.py b/pyfragment/methods/search_numbers.py index e82e32e..87b02fa 100644 --- a/pyfragment/methods/search_numbers.py +++ b/pyfragment/methods/search_numbers.py @@ -37,7 +37,7 @@ async def search_numbers( FragmentAPIError: If the Fragment API returns an error. 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: data["sort"] = sort if filter is not None: diff --git a/pyfragment/methods/search_usernames.py b/pyfragment/methods/search_usernames.py index 0fd4c39..890dff2 100644 --- a/pyfragment/methods/search_usernames.py +++ b/pyfragment/methods/search_usernames.py @@ -37,7 +37,7 @@ async def search_usernames( FragmentAPIError: If the Fragment API returns an error. 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: data["sort"] = sort if filter is not None: diff --git a/pyfragment/types/exceptions.py b/pyfragment/types/exceptions.py index be28af0..5f34a29 100644 --- a/pyfragment/types/exceptions.py +++ b/pyfragment/types/exceptions.py @@ -46,6 +46,7 @@ class CookieError(ClientError): "Fragment cookies not found in {browser}: {keys}. " "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): diff --git a/pyfragment/utils/cookies.py b/pyfragment/utils/cookies.py index 8e24495..7f9aaa4 100644 --- a/pyfragment/utils/cookies.py +++ b/pyfragment/utils/cookies.py @@ -61,6 +61,11 @@ def get_cookies_from_browser(browser: str = "chrome") -> CookieResult: continue 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( cookies={k: cookie_map[k] for k in REQUIRED_COOKIE_KEYS}, expires=expires_iso, diff --git a/pyfragment/utils/http.py b/pyfragment/utils/http.py index a9672d1..5009506 100644 --- a/pyfragment/utils/http.py +++ b/pyfragment/utils/http.py @@ -111,6 +111,8 @@ async def fragment_request( headers=headers, data=data, ) + 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")) diff --git a/pyfragment/utils/wallet.py b/pyfragment/utils/wallet.py index 49fdec6..7a67018 100644 --- a/pyfragment/utils/wallet.py +++ b/pyfragment/utils/wallet.py @@ -34,7 +34,7 @@ async def process_transaction(client: FragmentClient, transaction_data: dict[str 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 not transaction_data["transaction"].get("messages"): raise TransactionError(TransactionError.INVALID_PAYLOAD) message = transaction_data["transaction"]["messages"][0] diff --git a/tests/003_test_balance.py b/tests/003_test_balance.py index ab95492..6fa91b5 100644 --- a/tests/003_test_balance.py +++ b/tests/003_test_balance.py @@ -103,6 +103,12 @@ async def test_invalid_payload_raises() -> None: 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 async def test_balance_check_failed_raises_wallet_error() -> None: wallet = _make_wallet(balance_nanotons=1_000_000_000) diff --git a/tests/008_test_call.py b/tests/008_test_call.py index 14b6b59..78404cb 100644 --- a/tests/008_test_call.py +++ b/tests/008_test_call.py @@ -1,10 +1,13 @@ """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 from pyfragment import FragmentClient +from pyfragment.types import FragmentPageError +from pyfragment.utils.http import fragment_request from tests.shared import FAKE_HASH, FAKE_RESPONSE # 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 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"}) diff --git a/tests/014_test_cookies.py b/tests/014_test_cookies.py index 6f927b4..83d6c3d 100644 --- a/tests/014_test_cookies.py +++ b/tests/014_test_cookies.py @@ -106,6 +106,21 @@ def test_empty_cookie_value_treated_as_missing() -> None: 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 From d5dfed5f1a15f47407a69724925cc0205a26f2e3 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Sun, 3 May 2026 19:10:11 +0300 Subject: [PATCH 7/7] fix: update CI and publish workflows to use consistent action versions and improve error handling in API requests Co-authored-by: Copilot --- .github/workflows/ci.yml | 8 ++++---- .github/workflows/publish.yml | 20 ++++++++++---------- CHANGELOG.md | 16 ++++++++++++++++ pyfragment/utils/http.py | 25 +++++++++++++++++-------- pyfragment/utils/wallet.py | 5 +++-- pyproject.toml | 2 +- requirements-dev.txt | 1 - requirements.txt | 1 - 8 files changed, 51 insertions(+), 27 deletions(-) delete mode 100644 requirements-dev.txt delete mode 100644 requirements.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9572560..23d5f30 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,9 +14,9 @@ jobs: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: - - uses: actions/checkout@v6.0.2 + - uses: actions/checkout@v6 - - uses: actions/setup-python@v6.2.0 + - uses: actions/setup-python@v6 with: python-version: "3.10" @@ -36,9 +36,9 @@ jobs: python-version: [ "3.10", "3.11", "3.12" ] # 3.13, 3.14 are not supported by some dependencies yet steps: - - uses: actions/checkout@v6.0.2 + - uses: actions/checkout@v6 - - uses: actions/setup-python@v6.2.0 + - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 602a958..7620d90 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -18,7 +18,7 @@ jobs: is-new: ${{ steps.tag.outputs.is-new }} steps: - - uses: actions/checkout@v6.0.2 + - uses: actions/checkout@v6 with: fetch-depth: 0 @@ -46,17 +46,17 @@ jobs: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: - - uses: actions/checkout@v6.0.2 + - uses: actions/checkout@v6 - - uses: actions/setup-python@v6.2.0 + - uses: actions/setup-python@v6 with: python-version: "3.12" - - uses: astral-sh/setup-uv@v8.0.0 + - uses: astral-sh/setup-uv@v8.1.0 - run: uv build - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@v4 with: name: dist path: dist/* @@ -74,12 +74,12 @@ jobs: id-token: write steps: - - uses: actions/download-artifact@v8.0.1 + - uses: actions/download-artifact@v4 with: name: dist path: dist - - uses: pypa/gh-action-pypi-publish@v1.14.0 + - uses: pypa/gh-action-pypi-publish@v1 release: name: GitHub Release @@ -91,11 +91,11 @@ jobs: contents: write steps: - - uses: actions/checkout@v6.0.2 + - uses: actions/checkout@v6 with: fetch-depth: 0 - - uses: actions/download-artifact@v8.0.1 + - uses: actions/download-artifact@v4 with: name: dist path: dist @@ -108,7 +108,7 @@ jobs: echo "$body" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT - - uses: softprops/action-gh-release@v3.0.0 + - uses: softprops/action-gh-release@v3 with: tag_name: v${{ needs.version-check.outputs.version }} name: v${{ needs.version-check.outputs.version }} diff --git a/CHANGELOG.md b/CHANGELOG.md index ab4d62d..5423c88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ### 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 - `__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.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 diff --git a/pyfragment/utils/http.py b/pyfragment/utils/http.py index 5009506..4cfc206 100644 --- a/pyfragment/utils/http.py +++ b/pyfragment/utils/http.py @@ -1,5 +1,7 @@ from __future__ import annotations +import asyncio +import random import re from typing import Any, cast @@ -106,14 +108,21 @@ async def fragment_request( Returns: Parsed API response as a dict. """ - resp = await session.post( - f"{FRAGMENT_BASE_URL}/api?hash={fragment_hash}", - headers=headers, - data=data, - ) - 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")) + for attempt in range(3): + resp = await session.post( + f"{FRAGMENT_BASE_URL}/api?hash={fragment_hash}", + headers=headers, + data=data, + ) + 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( diff --git a/pyfragment/utils/wallet.py b/pyfragment/utils/wallet.py index 7a67018..d5c4ee6 100644 --- a/pyfragment/utils/wallet.py +++ b/pyfragment/utils/wallet.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio import base64 +import random import ssl from typing import TYPE_CHECKING, Any @@ -69,12 +70,12 @@ async def process_transaction(client: FragmentClient, transaction_data: dict[str return str(result.normalized_hash) except ProviderResponseError as exc: if exc.code == 429 and attempt == 0: - await asyncio.sleep(1) + await asyncio.sleep(1 + random.uniform(0, 0.5)) continue if exc.code == 406 and "seqno" in str(exc).lower(): # Previous tx seqno not yet confirmed — wallet will re-fetch seqno on retry if attempt < 2: - await asyncio.sleep(2) + await asyncio.sleep(2 + random.uniform(0, 1)) continue raise TransactionError(TransactionError.DUPLICATE_SEQNO) from exc raise diff --git a/pyproject.toml b/pyproject.toml index 32a72c0..d9fe781 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] 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." readme = "README.md" license = { text = "MIT" } diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index aefbcb6..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1 +0,0 @@ --e .[dev] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index d6e1198..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ --e .