From 9a090e3dbc35d36a9729c1d8da7501c5392fd7d7 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Sun, 15 Mar 2026 21:45:24 +0200 Subject: [PATCH] feat: implement CI/CD workflows for testing, linting, and publishing to PyPI --- .github/ISSUE_TEMPLATE/bug.yaml | 101 ++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/feature.yaml | 51 +++++++++ .github/PULL_REQUEST_TEMPLATE.md | 34 ++++++ .github/workflows/lint.yml | 31 ++++++ .github/workflows/publish.yml | 49 +++++++++ .github/workflows/tests.yml | 11 +- .gitignore | 13 +++ .../unicode_data/15.0.0/charmap.json.gz | Bin 21726 -> 0 bytes fragmentapi/methods/premium.py | 31 +++--- fragmentapi/methods/stars.py | 26 +++-- fragmentapi/methods/ton.py | 31 +++--- fragmentapi/types/__init__.py | 2 + fragmentapi/types/constants.py | 3 + fragmentapi/utils/__init__.py | 6 +- fragmentapi/utils/decoder.py | 15 +++ fragmentapi/utils/{client.py => http.py} | 33 +++++- fragmentapi/utils/wallet.py | 35 +++++- pyproject.toml | 14 ++- requirements-dev.txt | 1 + requirements.txt | 3 +- 21 files changed, 438 insertions(+), 57 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug.yaml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature.yaml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/publish.yml delete mode 100644 .hypothesis/unicode_data/15.0.0/charmap.json.gz rename fragmentapi/utils/{client.py => http.py} (76%) create mode 100644 requirements-dev.txt diff --git a/.github/ISSUE_TEMPLATE/bug.yaml b/.github/ISSUE_TEMPLATE/bug.yaml new file mode 100644 index 0000000..230a733 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yaml @@ -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. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..7fc7197 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -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. diff --git a/.github/ISSUE_TEMPLATE/feature.yaml b/.github/ISSUE_TEMPLATE/feature.yaml new file mode 100644 index 0000000..ae3d319 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yaml @@ -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. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..631b583 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -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 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..ed1ead3 --- /dev/null +++ b/.github/workflows/lint.yml @@ -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 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..bc243b9 --- /dev/null +++ b/.github/workflows/publish.yml @@ -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 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d698a55..4edd34f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,18 +8,21 @@ on: jobs: test: + name: Run tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v6.0.2 - - uses: actions/setup-python@v6 + - uses: actions/setup-python@v6.2.0 with: python-version: "3.12" - cache: "pip" + + - name: Install uv + uses: astral-sh/setup-uv@v7.5.0 - name: Install dependencies - run: pip install -r requirements.txt pytest pytest-asyncio + run: uv pip install --system ".[dev]" - name: Write cookies.json if: ${{ env.COOKIES_JSON != '' }} diff --git a/.gitignore b/.gitignore index 9cb8f25..082dca9 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,16 @@ logs/ .DS_Store Thumbs.db cookies.json + +# Testing & tooling artifacts +.hypothesis/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ + +# Build & distribution +dist/ +build/ +*.egg-info/ diff --git a/.hypothesis/unicode_data/15.0.0/charmap.json.gz b/.hypothesis/unicode_data/15.0.0/charmap.json.gz deleted file mode 100644 index c740c027cc85b3221abcca5382b71ad927e89785..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 21726 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 diff --git a/fragmentapi/methods/premium.py b/fragmentapi/methods/premium.py index b409d63..4f5cce6 100644 --- a/fragmentapi/methods/premium.py +++ b/fragmentapi/methods/premium.py @@ -17,9 +17,9 @@ from fragmentapi.types import ( ) from fragmentapi.utils import ( execute_transaction_request, + fragment_post, get_account_info, get_fragment_hash, - parse_json_response, process_transaction, ) @@ -40,16 +40,16 @@ async def _search_recipient( username: str, months: int, ) -> str: - resp = await session.post( - f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, - data={ + result = await fragment_post( + session, + fragment_hash, + HEADERS, + { "query": username, "months": months, "method": "searchPremiumGiftRecipient", }, ) - result = parse_json_response(resp, "searchPremiumGiftRecipient") recipient = result.get("found", {}).get("recipient") if not recipient: raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username)) @@ -62,26 +62,27 @@ async def _init_request( recipient: str, months: int, ) -> str: - await session.post( - f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, - data={ + await fragment_post( + session, + fragment_hash, + HEADERS, + { "mode": "new", "lv": "false", "dh": str(int(time.time())), "method": "updatePremiumState", }, ) - resp = await session.post( - f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, - data={ + result = await fragment_post( + session, + fragment_hash, + HEADERS, + { "recipient": recipient, "months": months, "method": "initGiftPremiumRequest", }, ) - result = parse_json_response(resp, "initGiftPremiumRequest") req_id = result.get("req_id") if not req_id: raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium purchase")) diff --git a/fragmentapi/methods/stars.py b/fragmentapi/methods/stars.py index 36bd271..9f15853 100644 --- a/fragmentapi/methods/stars.py +++ b/fragmentapi/methods/stars.py @@ -16,9 +16,9 @@ from fragmentapi.types import ( ) from fragmentapi.utils import ( execute_transaction_request, + fragment_post, get_account_info, get_fragment_hash, - parse_json_response, process_transaction, ) @@ -38,12 +38,16 @@ async def _search_recipient( fragment_hash: str, username: str, ) -> str: - resp = await session.post( - f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, - data={"query": username, "quantity": "", "method": "searchStarsRecipient"}, + result = await fragment_post( + session, + fragment_hash, + HEADERS, + { + "query": username, + "quantity": "", + "method": "searchStarsRecipient", + }, ) - result = parse_json_response(resp, "searchStarsRecipient") recipient = result.get("found", {}).get("recipient") if not recipient: raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username)) @@ -56,16 +60,16 @@ async def _init_request( recipient: str, amount: int, ) -> str: - resp = await session.post( - f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, - data={ + result = await fragment_post( + session, + fragment_hash, + HEADERS, + { "recipient": recipient, "quantity": amount, "method": "initBuyStarsRequest", }, ) - result = parse_json_response(resp, "initBuyStarsRequest") req_id = result.get("req_id") if not req_id: raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars purchase")) diff --git a/fragmentapi/methods/ton.py b/fragmentapi/methods/ton.py index 325d8e0..e4556cc 100644 --- a/fragmentapi/methods/ton.py +++ b/fragmentapi/methods/ton.py @@ -16,9 +16,9 @@ from fragmentapi.types import ( ) from fragmentapi.utils import ( execute_transaction_request, + fragment_post, get_account_info, get_fragment_hash, - parse_json_response, process_transaction, ) @@ -38,17 +38,16 @@ async def _search_recipient( fragment_hash: str, username: str, ) -> str: - await session.post( - f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, - data={"mode": "new", "method": "updateAdsTopupState"}, + await fragment_post(session, fragment_hash, HEADERS, {"mode": "new", "method": "updateAdsTopupState"}) + result = await fragment_post( + session, + 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") if not recipient: raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username)) @@ -61,16 +60,16 @@ async def _init_request( recipient: str, amount: int, ) -> str: - resp = await session.post( - f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, - data={ + result = await fragment_post( + session, + fragment_hash, + HEADERS, + { "recipient": recipient, "amount": amount, "method": "initAdsTopupRequest", }, ) - result = parse_json_response(resp, "initAdsTopupRequest") req_id = result.get("req_id") if not req_id: raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="TON topup")) diff --git a/fragmentapi/types/__init__.py b/fragmentapi/types/__init__.py index addcddd..13b69d4 100644 --- a/fragmentapi/types/__init__.py +++ b/fragmentapi/types/__init__.py @@ -1,6 +1,7 @@ from fragmentapi.types.constants import ( BASE_HEADERS, DEVICE, + MIN_TON_BALANCE, PREMIUM_PAGE, REQUIRED_COOKIE_KEYS, STARS_PAGE, @@ -30,6 +31,7 @@ __all__ = [ # constants "BASE_HEADERS", "DEVICE", + "MIN_TON_BALANCE", "PREMIUM_PAGE", "REQUIRED_COOKIE_KEYS", "STARS_PAGE", diff --git a/fragmentapi/types/constants.py b/fragmentapi/types/constants.py index 073d1e7..2bb799d 100644 --- a/fragmentapi/types/constants.py +++ b/fragmentapi/types/constants.py @@ -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_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_COOKIE_KEYS: tuple[str, ...] = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token") diff --git a/fragmentapi/utils/__init__.py b/fragmentapi/utils/__init__.py index c570db8..a888ac8 100644 --- a/fragmentapi/utils/__init__.py +++ b/fragmentapi/utils/__init__.py @@ -1,14 +1,16 @@ -from fragmentapi.utils.client import ( +from fragmentapi.utils.decoder import clean_decode +from fragmentapi.utils.http import ( execute_transaction_request, + fragment_post, get_fragment_hash, parse_json_response, ) -from fragmentapi.utils.decoder import clean_decode from fragmentapi.utils.wallet import get_account_info, process_transaction __all__ = [ "clean_decode", "execute_transaction_request", + "fragment_post", "get_account_info", "get_fragment_hash", "parse_json_response", diff --git a/fragmentapi/utils/decoder.py b/fragmentapi/utils/decoder.py index 43a9e75..77e076a 100644 --- a/fragmentapi/utils/decoder.py +++ b/fragmentapi/utils/decoder.py @@ -6,6 +6,21 @@ from fragmentapi.types import RequestError 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() if not s: return "" diff --git a/fragmentapi/utils/client.py b/fragmentapi/utils/http.py similarity index 76% rename from fragmentapi/utils/client.py rename to fragmentapi/utils/http.py index 53be7bc..0874645 100644 --- a/fragmentapi/utils/client.py +++ b/fragmentapi/utils/http.py @@ -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 +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( session: httpx.AsyncClient, headers: dict, @@ -97,9 +126,7 @@ async def execute_transaction_request( VerificationError: If Fragment requires KYC verification. RequestError: If the response cannot be parsed. """ - url = f"https://fragment.com/api?hash={fragment_hash}" - resp = await session.post(url, headers=headers, data=tx_data) - transaction = parse_json_response(resp, tx_data.get("method", "transaction")) + transaction = await fragment_post(session, fragment_hash, headers, tx_data) if transaction.get("need_verify"): raise VerificationError(VerificationError.KYC_REQUIRED) diff --git a/fragmentapi/utils/wallet.py b/fragmentapi/utils/wallet.py index e852bdc..01a0939 100644 --- a/fragmentapi/utils/wallet.py +++ b/fragmentapi/utils/wallet.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Any from tonutils.clients import TonapiClient 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 if TYPE_CHECKING: @@ -16,6 +16,22 @@ def _init_ton_client(client: "FragmentClient") -> TonapiClient: 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"]: raise TransactionError(TransactionError.INVALID_PAYLOAD) @@ -30,7 +46,7 @@ async def process_transaction(client: "FragmentClient", transaction_data: dict) try: await wallet.refresh() 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)) except WalletError: raise @@ -54,6 +70,21 @@ async def process_transaction(client: "FragmentClient", transaction_data: dict) 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: try: wallet_cls = WALLET_CLASSES[client.wallet_version] diff --git a/pyproject.toml b/pyproject.toml index 3a407fb..d62ec9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,14 @@ dependencies = [ "tonutils[pytoniq]==2.0.0", ] +[project.optional-dependencies] +dev = [ + "pytest>=9.0", + "pytest-asyncio>=1.0", + "ruff", + "black", +] + [project.urls] Homepage = "https://github.com/bohd4nx/FragmentAPI" Repository = "https://github.com/bohd4nx/FragmentAPI" @@ -49,11 +57,13 @@ line-length = 128 target-version = ["py312"] [tool.ruff] -target-version = "py312" line-length = 128 +target-version = "py312" [tool.ruff.lint] # E — pycodestyle errors, F — pyflakes, W — warnings, I — isort select = ["E", "F", "W", "I"] -# E501 — line too long (covered by line-length above) ignore = ["E501"] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["E402"] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..aefbcb6 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1 @@ +-e .[dev] diff --git a/requirements.txt b/requirements.txt index 5d930d9..d6e1198 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1 @@ -httpx==0.28.1 -tonutils[pytoniq]==2.0.0 +-e .