diff --git a/.github/workflows/unit-tests-all.yml b/.github/workflows/unit-tests-all.yml index 30c7876..3b0b92d 100644 --- a/.github/workflows/unit-tests-all.yml +++ b/.github/workflows/unit-tests-all.yml @@ -6,59 +6,79 @@ on: branches: - "main" jobs: - tox: - name: "Python ${{ matrix.python-version }} -- ${{ matrix.os }} " + unit-tests: + name: "Unit Tests - Python ${{ matrix.python-version }} -- ${{ matrix.os }}" runs-on: ${{ matrix.os }} - continue-on-error: ${{ matrix.experimental }} strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ["3.9", "3.10"] - experimental: [false] - # Include experimental or bleeding-edge releases. - # Windows is not included as it can be unreliable, e.g. - # psycopg2-binary is only released some time after a Python - # major/minor version is formally released. - # - # Uncomment below (including 'include:') when the next - # reasonable test candidate is made available: - include: - # - # Versions list: https://github.com/actions/python-versions/releases - # Example formatting: 3.11.0-alpha.1, 3.9.0-beta.8, 3.10.0-rc.3 - # - - os: ubuntu-latest - python-version: "3.11.0" - experimental: true - - os: windows-latest - python-version: "3.11.0" - experimental: true + python-version: ["3.10", "3.11", "3.12"] steps: - name: "check out repository" uses: "actions/checkout@v4" with: submodules: 'true' - name: "set up python ${{ matrix.python-version }}" - uses: "actions/setup-python@v4" + uses: "actions/setup-python@v5" with: python-version: "${{ matrix.python-version }}" - - name: "get pip cache dir" - id: "pip-cache" + - name: "cache pip packages" + uses: "actions/cache@v4" + with: + path: ~/.cache/pip + key: "${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}" + restore-keys: | + ${{ runner.os }}-pip- + - name: "install dependencies" + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: "run unit tests" run: | - echo "{name}={$(pip cache dir)}" >> $GITHUB_OUTPUT + pytest -c pytest.ini -m unit --cov=src/koios_api --cov-report=term-missing --cov-report=xml + - name: "upload coverage to codecov" + if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.11' + uses: codecov/codecov-action@v4 + with: + files: ./coverage.xml + flags: unittests + name: codecov-umbrella + fail_ci_if_error: false + + integration-tests: + name: "Integration Tests - Python ${{ matrix.python-version }} -- ${{ matrix.os }}" + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest] + python-version: ["3.11"] + steps: + - name: "check out repository" + uses: "actions/checkout@v4" + with: + submodules: 'true' + - name: "set up python ${{ matrix.python-version }}" + uses: "actions/setup-python@v5" + with: + python-version: "${{ matrix.python-version }}" - name: "cache pip packages" - uses: "actions/cache@v3" + uses: "actions/cache@v4" with: - path: "${{ steps.pip-cache.outputs.dir }}" - key: "${{ runner.os }}-pip-${{ hashFiles('**/base.txt', '**/local.txt') }}" + path: ~/.cache/pip + key: "${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}" restore-keys: | ${{ runner.os }}-pip- - - name: "install tox" + - name: "install dependencies" run: | python -m pip install --upgrade pip - pip install tox - - name: "run tox" - env: - TOXENV: py3 + pip install -r requirements.txt + - name: "run integration tests" run: | - tox + pytest -c pytest.ini -m integration --cov=src/koios_api --cov-report=term-missing --cov-report=xml + - name: "upload coverage to codecov" + uses: codecov/codecov-action@v4 + with: + files: ./coverage.xml + flags: integrationtests + name: codecov-umbrella + fail_ci_if_error: false diff --git a/.gitignore b/.gitignore index 33c2988..b65773b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ __pycache__ +.env .idea .pytest_cache .ruff_cache diff --git a/pytest.ini b/pytest.ini index 11d2aaa..9f16bca 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,5 +1,9 @@ [pytest] -addopts = -p no:cacheprovider +addopts = -p no:cacheprovider --cov=src/koios_api --cov-report=term-missing --cov-report=html +testpaths = tests +markers = + unit: Unit tests that do not require network access (fast, isolated) + integration: Integration tests that require the live Koios API (slow, network-dependent) [pycodestyle] ignore = diff --git a/requirements.txt b/requirements.txt index 78774a8..1894fac 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,5 +2,8 @@ codespell==2.3.0 pre-commit==3.7.1 pylint==3.2.3 pytest==8.2.2 +pytest-cov==5.0.0 +pytest-mock==3.14.0 +responses==0.25.0 tox==4.15.1 requests==2.32.3 diff --git a/src/koios_api/__init__.py b/src/koios_api/__init__.py index 63e2e44..3cd2a7f 100644 --- a/src/koios_api/__init__.py +++ b/src/koios_api/__init__.py @@ -1,12 +1,179 @@ -"""Module initialization""" -from .__config__ import * -from .account import * -from .address import * -from .asset import * -from .block import * -from .epoch import * -from .network import * -from .ogmios import * -from .pool import * -from .script import * -from .transactions import * +"""Koios API Python wrapper for Cardano blockchain data.""" + +from .__config__ import ( + API_BASE_URL, + API_RESP_COUNT, + CARDANO_NET, + KOIOS_API_TOKEN, + REQUEST_TIMEOUT, + SLEEP_TIME, +) +from .account import ( + get_account_addresses, + get_account_assets, + get_account_history, + get_account_info, + get_account_info_cached, + get_account_list, + get_account_rewards, + get_account_txs, + get_account_updates, + get_account_utxos, +) +from .address import ( + get_address_assets, + get_address_info, + get_address_txs, + get_address_utxos, + get_credential_txs, + get_credential_utxos, +) +from .asset import ( + get_asset_address_list, + get_asset_addresses, + get_asset_history, + get_asset_info, + get_asset_list, + get_asset_nft_address, + get_asset_policy_info, + get_asset_summary, + get_asset_token_registry, + get_asset_txs, + get_asset_utxos, + get_policy_asset_addresses, + get_policy_asset_info, + get_policy_asset_list, +) +from .block import get_block_info, get_block_txs, get_blocks +from .epoch import get_epoch_block_protocols, get_epoch_info, get_epoch_params +from .library import MaxRetriesExceeded +from .network import ( + get_genesis, + get_param_updates, + get_reserve_withdrawals, + get_tip, + get_totals, + get_treasury_withdrawals, +) +from .ogmios import get_ogmios +from .pool import ( + get_pool_blocks, + get_pool_delegators, + get_pool_delegators_history, + get_pool_history, + get_pool_info, + get_pool_list, + get_pool_metadata, + get_pool_registrations, + get_pool_relays, + get_pool_retirements, + get_pool_stake_snapshot, + get_pool_updates, + get_retiring_pools, +) +from .script import ( + get_datum_info, + get_native_script_list, + get_plutus_script_list, + get_script_info, + get_script_redeemers, + get_script_utxos, +) +from .transactions import ( + get_tx_info, + get_tx_metadata, + get_tx_metalabels, + get_tx_status, + get_utxo_info, + submit_tx, +) + +__all__ = [ + # Config + "API_BASE_URL", + "API_RESP_COUNT", + "CARDANO_NET", + "KOIOS_API_TOKEN", + "REQUEST_TIMEOUT", + "SLEEP_TIME", + # Exceptions + "MaxRetriesExceeded", + # Account + "get_account_addresses", + "get_account_assets", + "get_account_history", + "get_account_info", + "get_account_info_cached", + "get_account_list", + "get_account_rewards", + "get_account_txs", + "get_account_updates", + "get_account_utxos", + # Address + "get_address_assets", + "get_address_info", + "get_address_txs", + "get_address_utxos", + "get_credential_txs", + "get_credential_utxos", + # Asset + "get_asset_address_list", + "get_asset_addresses", + "get_asset_history", + "get_asset_info", + "get_asset_list", + "get_asset_nft_address", + "get_asset_policy_info", + "get_asset_summary", + "get_asset_txs", + "get_asset_utxos", + "get_asset_token_registry", + "get_policy_asset_addresses", + "get_policy_asset_info", + "get_policy_asset_list", + # Block + "get_block_info", + "get_block_txs", + "get_blocks", + # Epoch + "get_epoch_block_protocols", + "get_epoch_info", + "get_epoch_params", + # Network + "get_genesis", + "get_param_updates", + "get_reserve_withdrawals", + "get_tip", + "get_totals", + "get_treasury_withdrawals", + # Ogmios + "get_ogmios", + # Pool + "get_pool_blocks", + "get_pool_delegators", + "get_pool_delegators_history", + "get_pool_history", + "get_pool_info", + "get_pool_list", + "get_pool_metadata", + "get_pool_registrations", + "get_pool_relays", + "get_pool_retirements", + "get_pool_stake_snapshot", + "get_pool_updates", + "get_retiring_pools", + # Script + "get_datum_info", + "get_native_script_list", + "get_plutus_script_list", + "get_script_info", + "get_script_redeemers", + "get_script_utxos", + # Transactions + "get_tx_info", + "get_tx_metalabels", + "get_tx_metadata", + "get_tx_status", + "get_utxo_info", + "submit_tx", +] diff --git a/src/koios_api/account.py b/src/koios_api/account.py index 7fc7833..088a8db 100644 --- a/src/koios_api/account.py +++ b/src/koios_api/account.py @@ -1,10 +1,11 @@ """Account section functions""" -from typing import Union +from typing import Any, Union -from .library import * +from .__config__ import API_BASE_URL, API_RESP_COUNT +from .library import koios_post_request, paginated_get, paginated_post -def get_account_list(offset: int = 0, limit: int = 0) -> list: +def get_account_list(offset: int = 0, limit: int = 0) -> list[dict[str, Any]]: """ https://api.koios.rest/#get-/account_list Get a list of all stake addresses that have at least 1 transaction @@ -13,26 +14,10 @@ def get_account_list(offset: int = 0, limit: int = 0) -> list: :returns: The list of account (stake address) IDs """ url = API_BASE_URL + "/account_list" - parameters = {} - account_list = [] - while True: - if offset > 0: - parameters["offset"] = offset - resp = koios_get_request(url, parameters) - account_list += resp - if len(resp) < API_RESP_COUNT: - if 0 < limit <= len(account_list): - account_list = account_list[0:limit] - break - else: - offset += len(resp) - if 0 < limit <= len(account_list): - account_list = account_list[0:limit] - break - return account_list - - -def get_account_info(addr: Union[str, list]) -> list: + return paginated_get(url, {}, offset, limit) + + +def get_account_info(addr: Union[str, list[str]]) -> list[dict[str, Any]]: """ https://api.koios.rest/#post-/account_info Get the account information for given stake addresses (accounts) @@ -40,7 +25,7 @@ def get_account_info(addr: Union[str, list]) -> list: :returns: The list of account information """ url = API_BASE_URL + "/account_info" - parameters = {} + parameters: dict[str, Any] = {} if isinstance(addr, list): parameters["_stake_addresses"] = addr else: @@ -48,7 +33,7 @@ def get_account_info(addr: Union[str, list]) -> list: return koios_post_request(url, {}, parameters) -def get_account_info_cached(addr: Union[str, list]) -> list: +def get_account_info_cached(addr: Union[str, list[str]]) -> list[dict[str, Any]]: """ https://api.koios.rest/#post-/account_info_cached Get the cached account information for given stake addresses @@ -57,7 +42,7 @@ def get_account_info_cached(addr: Union[str, list]) -> list: :returns: The list of account information """ url = API_BASE_URL + "/account_info_cached" - parameters = {} + parameters: dict[str, Any] = {} if isinstance(addr, list): parameters["_stake_addresses"] = addr else: @@ -66,8 +51,8 @@ def get_account_info_cached(addr: Union[str, list]) -> list: def get_account_utxos( - addr: Union[str, list], extended: bool = False, offset: int = 0, limit: int = 0 -) -> list: + addr: Union[str, list[str]], extended: bool = False, offset: int = 0, limit: int = 0 +) -> list[dict[str, Any]]: """ https://api.koios.rest/#get-/account_utxos :param addr: Stake address(es), as a string (for one address) or a list (for multiple addresses) @@ -77,32 +62,16 @@ def get_account_utxos( :return: The list of all UTxOs for a given stake address (account) """ url = API_BASE_URL + "/account_utxos" - parameters = {} - qs_parameters = {"limit": API_RESP_COUNT} + parameters: dict[str, Any] = {} if isinstance(addr, list): parameters["_stake_addresses"] = addr else: parameters["_stake_addresses"] = [addr] parameters["_extended"] = str(extended).lower() - utxos = [] - while True: - if offset > 0: - qs_parameters["offset"] = offset - resp = koios_post_request(url, qs_parameters, parameters) - utxos += resp - if len(resp) < API_RESP_COUNT: - if 0 < limit <= len(utxos): - utxos = utxos[0:limit] - break - else: - offset += len(resp) - if 0 < limit <= len(utxos): - utxos = utxos[0:limit] - break - return utxos - - -def get_account_txs(addr: str, block_height: int = 0) -> list: + return paginated_post(url, {"limit": API_RESP_COUNT}, parameters, offset, limit) + + +def get_account_txs(addr: str, block_height: int = 0) -> list[dict[str, Any]]: """ https://api.koios.rest/#get-/account_txs Get a list of all Txs for a given stake address (account) @@ -111,24 +80,15 @@ def get_account_txs(addr: str, block_height: int = 0) -> list: :returns: The list of transactions associated with stake address (account) """ url = API_BASE_URL + "/account_txs" - parameters = {"_stake_address": addr} + parameters: dict[str, Any] = {"_stake_address": addr} if block_height > 0: parameters["_after_block_height"] = block_height - txs = [] - offset = 0 - while True: - if offset > 0: - parameters["offset"] = offset - resp = koios_get_request(url, parameters) - txs += resp - if len(resp) < API_RESP_COUNT: - break - else: - offset += len(resp) - return txs - - -def get_account_rewards(addr: Union[str, list], epoch: int = 0) -> list: + return paginated_get(url, parameters) + + +def get_account_rewards( + addr: Union[str, list[str]], epoch: int = 0 +) -> list[dict[str, Any]]: """ https://api.koios.rest/#post-/account_rewards Get the full rewards history (including MIR) for given stake addresses (accounts) @@ -137,18 +97,17 @@ def get_account_rewards(addr: Union[str, list], epoch: int = 0) -> list: :returns: The list of reward history information """ url = API_BASE_URL + "/account_rewards" - parameters = {} + parameters: dict[str, Any] = {} if isinstance(addr, list): parameters["_stake_addresses"] = addr else: parameters["_stake_addresses"] = [addr] if isinstance(epoch, int) and epoch > 0: parameters["_epoch_no"] = epoch - resp = koios_post_request(url, {}, parameters) - return resp + return koios_post_request(url, {}, parameters) -def get_account_updates(addr: Union[str, list]) -> list: +def get_account_updates(addr: Union[str, list[str]]) -> list[dict[str, Any]]: """ https://api.koios.rest/#post-/account_updates Get the account updates (registration, deregistration, delegation and withdrawals) for given stake addresses @@ -156,7 +115,7 @@ def get_account_updates(addr: Union[str, list]) -> list: :returns: The list of account updates information """ url = API_BASE_URL + "/account_updates" - parameters = {} + parameters: dict[str, Any] = {} if isinstance(addr, list): parameters["_stake_addresses"] = addr else: @@ -165,8 +124,8 @@ def get_account_updates(addr: Union[str, list]) -> list: def get_account_addresses( - addr: Union[str, list], first_only: bool = False, empty: bool = True -) -> list: + addr: Union[str, list[str]], first_only: bool = False, empty: bool = True +) -> list[dict[str, Any]]: """ https://api.koios.rest/#post-/account_addresses Get all addresses associated with given staking accounts @@ -176,7 +135,7 @@ def get_account_addresses( :returns: The list of payment addresses """ url = API_BASE_URL + "/account_addresses" - parameters = {} + parameters: dict[str, Any] = {} if isinstance(addr, list): parameters["_stake_addresses"] = addr else: @@ -186,7 +145,7 @@ def get_account_addresses( return koios_post_request(url, {}, parameters) -def get_account_assets(addr: Union[str, list]) -> list: +def get_account_assets(addr: Union[str, list[str]]) -> list[dict[str, Any]]: """ https://api.koios.rest/#post-/account_assets Get the native asset balance of given accounts @@ -194,27 +153,17 @@ def get_account_assets(addr: Union[str, list]) -> list: :returns: The list of assets owned by account """ url = API_BASE_URL + "/account_assets" - parameters = {} - qs_parameters = {"limit": API_RESP_COUNT} + parameters: dict[str, Any] = {} if isinstance(addr, list): parameters["_stake_addresses"] = addr else: parameters["_stake_addresses"] = [addr] - assets = [] - offset = 0 - while True: - if offset > 0: - qs_parameters["offset"] = offset - resp = koios_post_request(url, qs_parameters, parameters) - assets += resp - if len(resp) < API_RESP_COUNT: - break - else: - offset += len(resp) - return assets - - -def get_account_history(addr: Union[str, list], epoch: int = 0) -> list: + return paginated_post(url, {"limit": API_RESP_COUNT}, parameters) + + +def get_account_history( + addr: Union[str, list[str]], epoch: int = 0 +) -> list[dict[str, Any]]: """ https://api.koios.rest/#post-/account_history Get the staking history of given stake addresses (accounts) @@ -223,7 +172,7 @@ def get_account_history(addr: Union[str, list], epoch: int = 0) -> list: :returns: The list of active stake values per epoch """ url = API_BASE_URL + "/account_history" - parameters = {} + parameters: dict[str, Any] = {} if isinstance(addr, list): parameters["_stake_addresses"] = addr else: diff --git a/src/koios_api/address.py b/src/koios_api/address.py index bb58dc7..b4e102e 100644 --- a/src/koios_api/address.py +++ b/src/koios_api/address.py @@ -1,10 +1,11 @@ """Address section functions""" -from typing import Union +from typing import Any, Union -from .library import * +from .__config__ import API_BASE_URL, API_RESP_COUNT +from .library import koios_post_request, paginated_post -def get_address_info(addr: Union[str, list]) -> list: +def get_address_info(addr: Union[str, list[str]]) -> list[dict[str, Any]]: """ https://api.koios.rest/#post-/address_info Get address info - balance, associated stake address (if any) and UTxO set for given addresses @@ -12,7 +13,7 @@ def get_address_info(addr: Union[str, list]) -> list: :returns: The list of address information """ url = API_BASE_URL + "/address_info" - parameters = {} + parameters: dict[str, Any] = {} if isinstance(addr, list): parameters["_addresses"] = addr else: @@ -20,37 +21,29 @@ def get_address_info(addr: Union[str, list]) -> list: return koios_post_request(url, {}, parameters) -def get_address_utxos(addr: Union[str, list], extended: bool = False) -> list: +def get_address_utxos( + addr: Union[str, list[str]], extended: bool = False +) -> list[dict[str, Any]]: """ https://api.koios.rest/#post-/address_utxos Get UTxO set for given addresses - :param addr: Aaddress as string (for one address) or list (for multiple addresses) + :param addr: Address as string (for one address) or list (for multiple addresses) :param extended: (optional) Include certain optional fields are populated as a part of the call :returns: The list of address UTXOs """ url = API_BASE_URL + "/address_utxos" - parameters = {} - qs_parameters = {"limit": API_RESP_COUNT} + parameters: dict[str, Any] = {} if isinstance(addr, list): parameters["_addresses"] = addr else: parameters["_addresses"] = [addr] parameters["_extended"] = str(extended).lower() - utxos = [] - offset = 0 - while True: - if offset > 0: - qs_parameters["offset"] = offset - resp = koios_post_request(url, qs_parameters, parameters) - utxos += resp - if len(resp) < API_RESP_COUNT: - break - else: - offset += len(resp) - return utxos + return paginated_post(url, {"limit": API_RESP_COUNT}, parameters) -def get_credential_utxos(cred: Union[str, list], extended: bool = False) -> list: +def get_credential_utxos( + cred: Union[str, list[str]], extended: bool = False +) -> list[dict[str, Any]]: """ https://api.koios.rest/#post-/credential_utxos Get a list of UTxO against input payment credential array including their balances @@ -59,28 +52,18 @@ def get_credential_utxos(cred: Union[str, list], extended: bool = False) -> list :returns: The list of input payment credentials maps """ url = API_BASE_URL + "/credential_utxos" - parameters = {} - qs_parameters = {"limit": API_RESP_COUNT} + parameters: dict[str, Any] = {} if isinstance(cred, list): parameters["_payment_credentials"] = cred else: parameters["_payment_credentials"] = [cred] parameters["_extended"] = str(extended).lower() - utxos = [] - offset = 0 - while True: - if offset > 0: - qs_parameters["offset"] = offset - resp = koios_post_request(url, qs_parameters, parameters) - utxos += resp - if len(resp) < API_RESP_COUNT: - break - else: - offset += len(resp) - return utxos + return paginated_post(url, {"limit": API_RESP_COUNT}, parameters) -def get_address_txs(addr: Union[str, list], block_height: int = 0) -> list: +def get_address_txs( + addr: Union[str, list[str]], block_height: int = 0 +) -> list[dict[str, Any]]: """ https://api.koios.rest/#post-/address_txs Get the transaction hash list of input address array, optionally filtering after specified block height (inclusive) @@ -89,29 +72,19 @@ def get_address_txs(addr: Union[str, list], block_height: int = 0) -> list: :returns: The list of transaction hashes """ url = API_BASE_URL + "/address_txs" - parameters = {} - qs_parameters = {"limit": API_RESP_COUNT} + parameters: dict[str, Any] = {} if isinstance(addr, list): parameters["_addresses"] = addr else: parameters["_addresses"] = [addr] if block_height > 0: parameters["_after_block_height"] = block_height - txs = [] - offset = 0 - while True: - if offset > 0: - qs_parameters["offset"] = offset - resp = koios_post_request(url, qs_parameters, parameters) - txs += resp - if len(resp) < API_RESP_COUNT: - break - else: - offset += len(resp) - return txs + return paginated_post(url, {"limit": API_RESP_COUNT}, parameters) -def get_credential_txs(cred: Union[str, list], block_height: int = 0) -> list: +def get_credential_txs( + cred: Union[str, list[str]], block_height: int = 0 +) -> list[dict[str, Any]]: """ https://api.koios.rest/#post-/credential_txs Get the transaction hash list of input payment credential array, @@ -121,17 +94,17 @@ def get_credential_txs(cred: Union[str, list], block_height: int = 0) -> list: :returns: The list of transaction hashes """ url = API_BASE_URL + "/credential_txs" - parameters = {} + parameters: dict[str, Any] = {} if isinstance(cred, list): parameters["_payment_credentials"] = cred else: parameters["_payment_credentials"] = [cred] - if block_height: + if block_height > 0: parameters["_after_block_height"] = block_height - return koios_post_request(url, {}, parameters) + return paginated_post(url, {"limit": API_RESP_COUNT}, parameters) -def get_address_assets(addr: Union[str, list]) -> list: +def get_address_assets(addr: Union[str, list[str]]) -> list[dict[str, Any]]: """ https://api.koios.rest/#post-/address_assets Get the list of all the assets (policy, name and quantity) for given addresses @@ -139,21 +112,9 @@ def get_address_assets(addr: Union[str, list]) -> list: :returns: The list of address-owned assets """ url = API_BASE_URL + "/address_assets" - parameters = {} - qs_parameters = {"limit": API_RESP_COUNT} + parameters: dict[str, Any] = {} if isinstance(addr, list): parameters["_addresses"] = addr else: parameters["_addresses"] = [addr] - assets = [] - offset = 0 - while True: - if offset > 0: - qs_parameters["offset"] = offset - resp = koios_post_request(url, qs_parameters, parameters) - assets += resp - if len(resp) < API_RESP_COUNT: - break - else: - offset += len(resp) - return assets + return paginated_post(url, {"limit": API_RESP_COUNT}, parameters) diff --git a/src/koios_api/asset.py b/src/koios_api/asset.py index 001aa66..186b0f6 100644 --- a/src/koios_api/asset.py +++ b/src/koios_api/asset.py @@ -1,10 +1,14 @@ """Asset section functions""" -from typing import Union +import warnings +from typing import Any, Union -from .library import * +from .__config__ import API_BASE_URL, API_RESP_COUNT +from .library import koios_post_request, paginated_get, paginated_post -def get_asset_list(policy: str = "", offset: int = 0, limit: int = 0) -> list: +def get_asset_list( + policy: str = "", offset: int = 0, limit: int = 0 +) -> list[dict[str, Any]]: """ https://api.koios.rest/#get-/asset_list Get the list of all native assets (paginated) @@ -14,28 +18,15 @@ def get_asset_list(policy: str = "", offset: int = 0, limit: int = 0) -> list: :returns: The list of policy IDs and asset names """ url = API_BASE_URL + "/asset_list" - parameters = {} - assets_names_hex = [] - while True: - if offset > 0: - parameters["offset"] = offset - if isinstance(policy, str) and policy != "": - parameters["policy_id"] = "eq." + policy - resp = koios_get_request(url, parameters) - assets_names_hex += resp - if len(resp) < API_RESP_COUNT: - if 0 < limit <= len(assets_names_hex): - assets_names_hex = assets_names_hex[0:limit] - break - else: - offset += len(resp) - if 0 < limit <= len(assets_names_hex): - assets_names_hex = assets_names_hex[0:limit] - break - return assets_names_hex + parameters: dict[str, Any] = {} + if isinstance(policy, str) and policy != "": + parameters["policy_id"] = "eq." + policy + return paginated_get(url, parameters, offset, limit) -def get_policy_asset_list(policy: str, offset: int = 0, limit: int = 0) -> list: +def get_policy_asset_list( + policy: str, offset: int = 0, limit: int = 0 +) -> list[dict[str, Any]]: """ https://api.koios.rest/#get-/policy_asset_list Get the list of asset under the given policy (including balances) @@ -45,26 +36,11 @@ def get_policy_asset_list(policy: str, offset: int = 0, limit: int = 0) -> list: :returns: The list of detailed information of assets under the same policy """ url = API_BASE_URL + "/policy_asset_info" - parameters = {"_asset_policy": policy} - assets = [] - while True: - if offset > 0: - parameters["offset"] = offset - resp = koios_get_request(url, parameters) - assets += resp - if len(resp) < API_RESP_COUNT: - if 0 < limit <= len(assets): - assets = assets[0:limit] - break - else: - offset += len(resp) - if 0 < limit <= len(assets): - assets = assets[0:limit] - break - return assets + parameters: dict[str, Any] = {"_asset_policy": policy} + return paginated_get(url, parameters, offset, limit) -def get_asset_token_registry(logo: bool = True) -> list: +def get_asset_token_registry(logo: bool = True) -> list[dict[str, Any]]: """ https://api.koios.rest/#get-/asset_token_registry Get a list of assets registered via token registry on github @@ -72,26 +48,15 @@ def get_asset_token_registry(logo: bool = True) -> list: :returns: The list of token registry information for each asset """ url = API_BASE_URL + "/asset_token_registry" - parameters = {"order": "policy_id.asc,asset_name.asc"} - assets_token_registry = [] - offset = 0 - while True: - if offset > 0: - parameters["offset"] = offset - if not logo: - parameters[ - "select" - ] = "policy_id,asset_name,asset_name_ascii,ticker,description,url,decimals" - resp = koios_get_request(url, parameters) - assets_token_registry += resp - if len(resp) < API_RESP_COUNT: - break - else: - offset += len(resp) - return assets_token_registry + parameters: dict[str, Any] = {"order": "policy_id.asc,asset_name.asc"} + if not logo: + parameters[ + "select" + ] = "policy_id,asset_name,asset_name_ascii,ticker,description,url,decimals" + return paginated_get(url, parameters) -def get_asset_info(assets: Union[str, list]) -> list: +def get_asset_info(assets: Union[str, list[str]]) -> list[dict[str, Any]]: """ https://api.koios.rest/#post-/asset_info Get the information of a list of assets including first minting & token registry metadata @@ -99,7 +64,7 @@ def get_asset_info(assets: Union[str, list]) -> list: :returns: List of detailed asset information """ url = API_BASE_URL + "/asset_info" - parameters = {"_asset_list": []} + parameters: dict[str, Any] = {"_asset_list": []} if isinstance(assets, str): asset_list = [assets] else: @@ -110,7 +75,9 @@ def get_asset_info(assets: Union[str, list]) -> list: return koios_post_request(url, {}, parameters) -def get_asset_utxos(assets: Union[str, list], extended: bool = False) -> list: +def get_asset_utxos( + assets: Union[str, list[str]], extended: bool = False +) -> list[dict[str, Any]]: """ https://api.koios.rest/#post-/asset_utxos Get the UTXO information of a list of assets @@ -119,8 +86,7 @@ def get_asset_utxos(assets: Union[str, list], extended: bool = False) -> list: :returns: The list UTXOs for given asset list """ url = API_BASE_URL + "/asset_utxos" - parameters = {"_asset_list": []} - qs_parameters = {"limit": API_RESP_COUNT} + parameters: dict[str, Any] = {"_asset_list": []} if isinstance(assets, str): asset_list = [assets] else: @@ -129,21 +95,10 @@ def get_asset_utxos(assets: Union[str, list], extended: bool = False) -> list: asset_split = asset.split(".") parameters["_asset_list"].append([asset_split[0], asset_split[1]]) parameters["_extended"] = str(extended).lower() - utxos = [] - offset = 0 - while True: - if offset > 0: - qs_parameters["offset"] = offset - resp = koios_post_request(url, qs_parameters, parameters) - utxos += resp - if len(resp) < API_RESP_COUNT: - break - else: - offset += len(resp) - return utxos + return paginated_post(url, {"limit": API_RESP_COUNT}, parameters) -def get_asset_history(policy: str, name: str = "") -> list: +def get_asset_history(policy: str, name: str = "") -> list[dict[str, Any]]: """ https://api.koios.rest/#get-/asset_history Get the mint/burn history of an asset @@ -152,24 +107,13 @@ def get_asset_history(policy: str, name: str = "") -> list: :returns: The list of asset mint/burn history """ url = API_BASE_URL + "/asset_history" - parameters = {"_asset_policy": policy} + parameters: dict[str, Any] = {"_asset_policy": policy} if isinstance(name, str) and name != "": parameters["_asset_name"] = name - assets = [] - offset = 0 - while True: - if offset > 0: - parameters["offset"] = offset - resp = koios_get_request(url, parameters) - assets += resp - if len(resp) < API_RESP_COUNT: - break - else: - offset += len(resp) - return assets + return paginated_get(url, parameters) -def get_asset_addresses(policy: str, name: str = "") -> list: +def get_asset_addresses(policy: str, name: str = "") -> list[dict[str, Any]]: """ https://api.koios.rest/#get-/asset_addresses Get the list of all addresses holding a given asset @@ -178,24 +122,13 @@ def get_asset_addresses(policy: str, name: str = "") -> list: :returns: The list of payment addresses holding the given token (including balances) """ url = API_BASE_URL + "/asset_addresses" - parameters = {"_asset_policy": policy} + parameters: dict[str, Any] = {"_asset_policy": policy} if isinstance(name, str) and name != "": parameters["_asset_name"] = name - wallets = [] - offset = 0 - while True: - if offset > 0: - parameters["offset"] = offset - resp = koios_get_request(url, parameters) - wallets += resp - if len(resp) < API_RESP_COUNT: - break - else: - offset += len(resp) - return wallets + return paginated_get(url, parameters) -def get_asset_nft_address(policy: str, name: str = "") -> list: +def get_asset_nft_address(policy: str, name: str = "") -> list[dict[str, Any]]: """ https://api.koios.rest/#get-/asset_nft_address Get the address where specified NFT currently reside on @@ -204,22 +137,13 @@ def get_asset_nft_address(policy: str, name: str = "") -> list: :returns: The list of payment addresses currently holding the given NFT """ url = API_BASE_URL + "/asset_nft_address" - parameters = {"_asset_policy": policy, "_asset_name": name} - wallets = [] - offset = 0 - while True: - if offset > 0: - parameters["offset"] = offset - resp = koios_get_request(url, parameters) - wallets += resp - if len(resp) < API_RESP_COUNT: - break - else: - offset += len(resp) - return wallets + parameters: dict[str, Any] = {"_asset_policy": policy, "_asset_name": name} + return paginated_get(url, parameters) -def get_policy_asset_addresses(policy: str, offset: int = 0, limit: int = 0) -> list: +def get_policy_asset_addresses( + policy: str, offset: int = 0, limit: int = 0 +) -> list[dict[str, Any]]: """ https://api.koios.rest/#get-/policy_asset_addresses Get the list of addresses with quantity for each asset on the given policy @@ -229,26 +153,13 @@ def get_policy_asset_addresses(policy: str, offset: int = 0, limit: int = 0) -> :returns: The list of asset names and payment addresses for the given policy (including balances) """ url = API_BASE_URL + "/policy_asset_addresses" - parameters = {"_asset_policy": policy} - asset_addresses = [] - while True: - if offset > 0: - parameters["offset"] = offset - resp = koios_get_request(url, parameters) - asset_addresses += resp - if len(resp) < API_RESP_COUNT: - if 0 < limit <= len(asset_addresses): - asset_addresses = asset_addresses[0:limit] - break - else: - offset += len(resp) - if 0 < limit <= len(asset_addresses): - asset_addresses = asset_addresses[0:limit] - break - return asset_addresses + parameters: dict[str, Any] = {"_asset_policy": policy} + return paginated_get(url, parameters, offset, limit) -def get_policy_asset_info(policy: str, offset: int = 0, limit: int = 0) -> list: +def get_policy_asset_info( + policy: str, offset: int = 0, limit: int = 0 +) -> list[dict[str, Any]]: """ https://api.koios.rest/#get-/policy_asset_info Get the information for all assets under the same policy @@ -258,26 +169,11 @@ def get_policy_asset_info(policy: str, offset: int = 0, limit: int = 0) -> list: :returns: The list of detailed information of assets under the same policy """ url = API_BASE_URL + "/policy_asset_info" - parameters = {"_asset_policy": policy} - assets = [] - while True: - if offset > 0: - parameters["offset"] = offset - resp = koios_get_request(url, parameters) - assets += resp - if len(resp) < API_RESP_COUNT: - if 0 < limit <= len(assets): - assets = assets[0:limit] - break - else: - offset += len(resp) - if 0 < limit <= len(assets): - assets = assets[0:limit] - break - return assets + parameters: dict[str, Any] = {"_asset_policy": policy} + return paginated_get(url, parameters, offset, limit) -def get_asset_summary(policy: str, name: str = "") -> list: +def get_asset_summary(policy: str, name: str = "") -> list[dict[str, Any]]: """ Get the summary of an asset (total transactions exclude minting/total wallets include only wallets with asset balance) @@ -286,110 +182,67 @@ def get_asset_summary(policy: str, name: str = "") -> list: :returns: The list of asset summary information """ url = API_BASE_URL + "/asset_summary" - parameters = {"_asset_policy": policy, "_asset_name": name} + parameters: dict[str, Any] = {"_asset_policy": policy, "_asset_name": name} return koios_post_request(url, {}, parameters) def get_asset_txs( policy: str, name: str = "", block_height: int = 0, history: bool = False -) -> list: +) -> list[dict[str, Any]]: """ Get the list of all asset transaction hashes (the newest first) :param policy: Asset Policy :param name: Asset Name in hexadecimal format (optional), default: all policy assets - :param block: (optional) Return only the transactions after this block + :param block_height: (optional) Return only the transactions after this block height :param history: (optional) Include all historical transactions, setting to false includes only the non-empty ones :returns: The list of Tx hashes that included the given asset (latest first) """ url = API_BASE_URL + "/asset_txs" - parameters = { + parameters: dict[str, Any] = { "_asset_policy": policy, "_asset_name": name, "_after_block_height": block_height, "_history": str(history).lower(), } - assets_txs = [] - offset = 0 - while True: - if offset > 0: - parameters["offset"] = offset - resp = koios_get_request(url, parameters) - assets_txs += resp - if len(resp) < API_RESP_COUNT: - break - else: - offset += len(resp) - return assets_txs + return paginated_get(url, parameters) -def get_asset_address_list(policy: str, name: str = "") -> list: +def get_asset_address_list(policy: str, name: str = "") -> list[dict[str, Any]]: """ - DEPRECATED + DEPRECATED: Use get_asset_addresses instead. + https://api.koios.rest/#get-/asset_address_list Get the list of all addresses holding a given asset :param policy: Asset Policy :param name: Asset Name in hexadecimal format (optional), default: all policy assets :returns: List of maps with the wallets holding the asset and the amount of assets per wallet """ - url = API_BASE_URL + f"/asset_address_list?_asset_policy={policy}" + warnings.warn( + "get_asset_address_list is deprecated, use get_asset_addresses instead", + DeprecationWarning, + stacklevel=2, + ) + url = API_BASE_URL + "/asset_address_list" + parameters: dict[str, Any] = {"_asset_policy": policy} if isinstance(name, str) and name != "": - url += f"&_asset_name={name}" - wallets = [] - offset = 0 - while True: - paginated_url = url + f"&offset={offset}" - while True: - try: - response = requests.get(paginated_url, timeout=REQUEST_TIMEOUT) - if response.status_code == 200: - resp = json.loads(response.text) - break - else: - logger.warning(f"status code: {response.status_code}, retrying...") - except Exception as exc: - logger.exception( - f"Exception in {inspect.getframeinfo(inspect.currentframe()).function}: {exc}" - ) - sleep(SLEEP_TIME) - logger.warning(f"offset: {offset}, retrying...") - wallets += resp - if len(resp) < API_RESP_COUNT: - break - else: - offset += len(resp) - return wallets + parameters["_asset_name"] = name + return paginated_get(url, parameters) -def get_asset_policy_info(policy: str) -> list: +def get_asset_policy_info(policy: str) -> list[dict[str, Any]]: """ - DEPRECATED + DEPRECATED: Use get_policy_asset_info instead. + https://api.koios.rest/#get-/asset_policy_info Get the information for all assets under the same policy :param policy: Asset Policy :returns: List of maps with the policy assets """ - url = API_BASE_URL + f"/asset_policy_info?_asset_policy={policy}" - assets = [] - offset = 0 - while True: - paginated_url = url + f"&offset={offset}" - while True: - try: - response = requests.get(paginated_url, timeout=REQUEST_TIMEOUT) - if response.status_code == 200: - resp = json.loads(response.text) - break - else: - logger.warning(f"status code: {response.status_code}, retrying...") - except Exception as exc: - logger.exception( - f"Exception in {inspect.getframeinfo(inspect.currentframe()).function}: {exc}" - ) - sleep(SLEEP_TIME) - logger.warning(f"offset: {offset}, retrying...") - assets += resp - if len(resp) < API_RESP_COUNT: - break - else: - offset += len(resp) - return assets + warnings.warn( + "get_asset_policy_info is deprecated, use get_policy_asset_info instead", + DeprecationWarning, + stacklevel=2, + ) + url = API_BASE_URL + "/asset_policy_info" + parameters: dict[str, Any] = {"_asset_policy": policy} + return paginated_get(url, parameters) diff --git a/src/koios_api/block.py b/src/koios_api/block.py index 5c136ca..40c4a4a 100644 --- a/src/koios_api/block.py +++ b/src/koios_api/block.py @@ -1,10 +1,11 @@ """Block section functions""" -from typing import Union +from typing import Any, Union -from .library import * +from .__config__ import API_BASE_URL +from .library import koios_post_request, paginated_get -def get_blocks(limit: int = 0) -> list: +def get_blocks(limit: int = 0) -> list[dict[str, Any]]: """ https://api.koios.rest/#get-/blocks Get summarised details about all blocks (paginated - latest first) @@ -12,28 +13,13 @@ def get_blocks(limit: int = 0) -> list: :returns: The list of block information (the newest first) """ url = API_BASE_URL + "/blocks" - parameters = {} - blocks = [] - offset = 0 - while True: - if offset > 0: - parameters["offset"] = offset - if isinstance(limit, int) and limit > 0: - parameters["limit"] = limit - resp = koios_get_request(url, parameters) - blocks += resp - if len(resp) < API_RESP_COUNT: - break - else: - offset += len(resp) - if isinstance(limit, int) and len(blocks) > limit: - break + parameters: dict[str, Any] = {} if isinstance(limit, int) and limit > 0: - blocks = blocks[0:limit] - return blocks + parameters["limit"] = limit + return paginated_get(url, parameters, limit=limit) -def get_block_info(block: Union[str, list]) -> list: +def get_block_info(block: Union[str, list[str]]) -> list[dict[str, Any]]: """ https://api.koios.rest/#post-/block_info Get detailed information about a specific block @@ -41,7 +27,7 @@ def get_block_info(block: Union[str, list]) -> list: :returns: The list of detailed block information """ url = API_BASE_URL + "/block_info" - parameters = {} + parameters: dict[str, Any] = {} if isinstance(block, list): parameters["_block_hashes"] = block else: @@ -49,7 +35,7 @@ def get_block_info(block: Union[str, list]) -> list: return koios_post_request(url, {}, parameters) -def get_block_txs(block: Union[str, list]) -> list: +def get_block_txs(block: Union[str, list[str]]) -> list[dict[str, Any]]: """ https://api.koios.rest/#post-/block_txs Get a list of all transactions included in provided blocks @@ -57,7 +43,7 @@ def get_block_txs(block: Union[str, list]) -> list: :returns: The list of transactions hashes """ url = API_BASE_URL + "/block_txs" - parameters = {} + parameters: dict[str, Any] = {} if isinstance(block, list): parameters["_block_hashes"] = block else: diff --git a/src/koios_api/epoch.py b/src/koios_api/epoch.py index b84c5e2..50b5198 100644 --- a/src/koios_api/epoch.py +++ b/src/koios_api/epoch.py @@ -1,8 +1,13 @@ """Epoch section functions""" -from .library import * +from typing import Any +from .__config__ import API_BASE_URL +from .library import koios_get_request -def get_epoch_info(epoch: int = 0, include_next_epoch: bool = False) -> list: + +def get_epoch_info( + epoch: int = 0, include_next_epoch: bool = False +) -> list[dict[str, Any]]: """ https://api.koios.rest/#get-/epoch_info Get the epoch information, all epochs if no epoch specified @@ -12,7 +17,7 @@ def get_epoch_info(epoch: int = 0, include_next_epoch: bool = False) -> list: :returns: The list of detailed summary for each epoch """ url = API_BASE_URL + "/epoch_info" - parameters = {} + parameters: dict[str, Any] = {} if isinstance(epoch, int) and epoch > 0: parameters["_epoch_no"] = epoch if isinstance(include_next_epoch, bool): @@ -20,7 +25,7 @@ def get_epoch_info(epoch: int = 0, include_next_epoch: bool = False) -> list: return koios_get_request(url, parameters) -def get_epoch_params(epoch: int = 0) -> list: +def get_epoch_params(epoch: int = 0) -> list[dict[str, Any]]: """ https://api.koios.rest/#get-/epoch_params Get the protocol parameters for specific epoch, returns information about all epochs if no epoch specified @@ -28,13 +33,13 @@ def get_epoch_params(epoch: int = 0) -> list: :returns: The list of protocol parameters for each epoch """ url = API_BASE_URL + "/epoch_params" - parameters = {} + parameters: dict[str, Any] = {} if isinstance(epoch, int) and epoch > 0: parameters["_epoch_no"] = epoch return koios_get_request(url, parameters) -def get_epoch_block_protocols(epoch: int = 0) -> list: +def get_epoch_block_protocols(epoch: int = 0) -> list[dict[str, Any]]: """ https://api.koios.rest/#get-/epoch_block_protocols Get the information about block protocol distribution in epoch @@ -42,7 +47,7 @@ def get_epoch_block_protocols(epoch: int = 0) -> list: :returns: The list of distinct block protocol versions counts in epoch """ url = API_BASE_URL + "/epoch_block_protocols" - parameters = {} + parameters: dict[str, Any] = {} if isinstance(epoch, int) and epoch > 0: parameters["_epoch_no"] = epoch return koios_get_request(url, parameters) diff --git a/src/koios_api/library.py b/src/koios_api/library.py index 31640a2..3d12f31 100644 --- a/src/koios_api/library.py +++ b/src/koios_api/library.py @@ -2,10 +2,24 @@ import inspect import json from time import sleep +from typing import Any, Optional import requests -from .__config__ import * +from .__config__ import ( + API_RESP_COUNT, + KOIOS_API_TOKEN, + REQUEST_TIMEOUT, + SLEEP_TIME, + logger, +) + +# Maximum number of retries for failed requests +MAX_RETRIES = 10 + + +class MaxRetriesExceeded(Exception): + """Exception raised when maximum retries are exceeded.""" def get_error_message(response: requests.Response) -> str: @@ -15,22 +29,25 @@ def get_error_message(response: requests.Response) -> str: :return: The error message """ try: - error_message = json.loads(response.text)["message"] - except json.decoder.JSONDecodeError: + error_message = json.loads(response.text).get("message", "") + if not error_message: + error_message = response.text + except (json.decoder.JSONDecodeError, KeyError): error_message = response.text if not error_message: error_message = response.reason - if "deny-reason" in response.headers: - error_message += " " + response.headers["deny-reason"] + if "deny-reason" in response.headers: + error_message += " " + response.headers["deny-reason"] return error_message -def koios_get_request(url: str, parameters: dict) -> list: +def koios_get_request(url: str, parameters: dict[str, Any]) -> list[dict[str, Any]]: """ Create a GET request to Koios API using the "requests" library and return the text of the response as a list :param url: URL :param parameters: Parameters to include as data in the GET request :return: A list with the body of the response + :raises MaxRetriesExceeded: When maximum retries are exceeded """ headers = {"Accept": "application/json", "Content-Type": "application/json"} if KOIOS_API_TOKEN: @@ -47,35 +64,42 @@ def koios_get_request(url: str, parameters: dict) -> list: ] if any(req in url for req in ordered_requests): parameters["order"] = "block_height.asc" - while True: + + for attempt in range(MAX_RETRIES): try: response = requests.get( url, headers=headers, params=parameters, timeout=REQUEST_TIMEOUT ) if response.status_code == 200: resp = json.loads(response.text) - break - else: - error_message = get_error_message(response) - logger.warning( - f"status code: {response.status_code} ({error_message}), retrying..." - ) - logger.error(inspect.stack()[-1]) - sleep(SLEEP_TIME) + return resp + error_message = get_error_message(response) + logger.warning( + f"status code: {response.status_code} ({error_message}), " + f"attempt {attempt + 1}/{MAX_RETRIES}, retrying..." + ) + logger.error(inspect.stack()[-1]) + sleep(SLEEP_TIME) except Exception as exc: logger.exception( f"Exception in {inspect.getframeinfo(inspect.currentframe()).function}: {exc}" ) - if "offset" in parameters: - offset = {parameters["offset"]} - else: - offset = 0 - logger.warning(f"offset: {offset}, retrying in {SLEEP_TIME} second(s)...") + offset = parameters.get("offset", 0) + logger.warning( + f"offset: {offset}, attempt {attempt + 1}/{MAX_RETRIES}, " + f"retrying in {SLEEP_TIME} second(s)..." + ) sleep(SLEEP_TIME) - return resp + raise MaxRetriesExceeded(f"Failed to get {url} after {MAX_RETRIES} attempts") -def koios_post_request(url: str, params: dict, parameters: dict, headers=None) -> list: + +def koios_post_request( + url: str, + params: dict[str, Any], + parameters: dict[str, Any], + headers: Optional[dict[str, str]] = None, +) -> list[dict[str, Any]]: """ Create a POST request to Koios API using the "requests" library and return the text of the response as a list :param url: URL @@ -83,13 +107,13 @@ def koios_post_request(url: str, params: dict, parameters: dict, headers=None) - :param parameters: Parameters to include as data in the POST request :param headers: Headers to include in the request :return: A list with the body of the response + :raises MaxRetriesExceeded: When maximum retries are exceeded """ if headers is None: - headers = {} - if not headers: headers = {"Accept": "application/json", "Content-Type": "application/json"} if KOIOS_API_TOKEN: headers["Authorization"] = "Bearer " + KOIOS_API_TOKEN + ordered_requests = [ "utxo_info", "tx_info", @@ -102,7 +126,8 @@ def koios_post_request(url: str, params: dict, parameters: dict, headers=None) - ] if any(req in url for req in ordered_requests): params["order"] = "block_height.asc" - while True: + + for attempt in range(MAX_RETRIES): try: response = requests.post( url, @@ -113,22 +138,129 @@ def koios_post_request(url: str, params: dict, parameters: dict, headers=None) - ) if response.status_code == 200: resp = json.loads(response.text) - break - else: - error_message = get_error_message(response) - logger.warning( - f"status code: {response.status_code} ({error_message}), retrying..." - ) - logger.error(inspect.stack()[-1]) - sleep(SLEEP_TIME) + return resp + error_message = get_error_message(response) + logger.warning( + f"status code: {response.status_code} ({error_message}), " + f"attempt {attempt + 1}/{MAX_RETRIES}, retrying..." + ) + logger.error(inspect.stack()[-1]) + sleep(SLEEP_TIME) + except Exception as exc: + logger.exception( + f"Exception in {inspect.getframeinfo(inspect.currentframe()).function}: {exc}" + ) + offset = parameters.get("offset", 0) if isinstance(parameters, dict) else 0 + logger.warning( + f"offset: {offset}, attempt {attempt + 1}/{MAX_RETRIES}, " + f"retrying in {SLEEP_TIME} second(s)..." + ) + sleep(SLEEP_TIME) + + raise MaxRetriesExceeded(f"Failed to post to {url} after {MAX_RETRIES} attempts") + + +def koios_post_request_raw( + url: str, + data: bytes, + headers: dict[str, str], +) -> Any: + """ + Create a POST request to Koios API with raw data (e.g., CBOR) + :param url: URL + :param data: Raw data to include in the POST request body + :param headers: Headers to include in the request + :return: The response body + :raises MaxRetriesExceeded: When maximum retries are exceeded + """ + if KOIOS_API_TOKEN: + headers["Authorization"] = "Bearer " + KOIOS_API_TOKEN + + for attempt in range(MAX_RETRIES): + try: + response = requests.post( + url, + headers=headers, + data=data, + timeout=REQUEST_TIMEOUT, + ) + if response.status_code in (200, 202): + return json.loads(response.text) + error_message = get_error_message(response) + logger.warning( + f"status code: {response.status_code} ({error_message}), " + f"attempt {attempt + 1}/{MAX_RETRIES}, retrying..." + ) + logger.error(inspect.stack()[-1]) + sleep(SLEEP_TIME) except Exception as exc: logger.exception( f"Exception in {inspect.getframeinfo(inspect.currentframe()).function}: {exc}" ) - if "offset" in parameters: - offset = {parameters["offset"]} - else: - offset = 0 - logger.warning(f"offset: {offset}, retrying in {SLEEP_TIME} second(s)...") + logger.warning( + f"attempt {attempt + 1}/{MAX_RETRIES}, " + f"retrying in {SLEEP_TIME} second(s)..." + ) sleep(SLEEP_TIME) - return resp + + raise MaxRetriesExceeded(f"Failed to post to {url} after {MAX_RETRIES} attempts") + + +def paginated_get( + url: str, + parameters: dict[str, Any], + offset: int = 0, + limit: int = 0, +) -> list[dict[str, Any]]: + """ + Perform a paginated GET request to Koios API + :param url: URL + :param parameters: Parameters to include in the GET request + :param offset: The offset to start from (optional) + :param limit: The maximum number of results to return (optional, 0 = unlimited) + :return: A list with all paginated results + """ + results: list[dict[str, Any]] = [] + while True: + if offset > 0: + parameters["offset"] = offset + resp = koios_get_request(url, parameters) + results += resp + if len(resp) < API_RESP_COUNT: + break + offset += len(resp) + if 0 < limit <= len(results): + return results[:limit] + return results[:limit] if limit > 0 else results + + +def paginated_post( + url: str, + qs_parameters: dict[str, Any], + parameters: dict[str, Any], + offset: int = 0, + limit: int = 0, +) -> list[dict[str, Any]]: + """ + Perform a paginated POST request to Koios API + :param url: URL + :param qs_parameters: Parameters to include in the query string + :param parameters: Parameters to include as data in the POST request + :param offset: The offset to start from (optional) + :param limit: The maximum number of results to return (optional, 0 = unlimited) + :return: A list with all paginated results + """ + if "limit" not in qs_parameters: + qs_parameters["limit"] = API_RESP_COUNT + results: list[dict[str, Any]] = [] + while True: + if offset > 0: + qs_parameters["offset"] = offset + resp = koios_post_request(url, qs_parameters, parameters) + results += resp + if len(resp) < API_RESP_COUNT: + break + offset += len(resp) + if 0 < limit <= len(results): + return results[:limit] + return results[:limit] if limit > 0 else results diff --git a/src/koios_api/network.py b/src/koios_api/network.py index 1a40038..1d8b4a0 100644 --- a/src/koios_api/network.py +++ b/src/koios_api/network.py @@ -1,8 +1,11 @@ """Network section functions""" -from .library import * +from typing import Any +from .__config__ import API_BASE_URL +from .library import koios_get_request, paginated_get -def get_tip() -> list: + +def get_tip() -> list[dict[str, Any]]: """ https://api.koios.rest/#get-/tip Get the tip info about the latest block seen by chain @@ -12,7 +15,7 @@ def get_tip() -> list: return koios_get_request(url, {}) -def get_genesis() -> list: +def get_genesis() -> list[dict[str, Any]]: """ https://api.koios.rest/#get-/genesis Get the Genesis parameters used to start specific era on chain @@ -22,7 +25,7 @@ def get_genesis() -> list: return koios_get_request(url, {}) -def get_totals(epoch: int = 0) -> list: +def get_totals(epoch: int = 0) -> list[dict[str, Any]]: """ https://api.koios.rest/#get-/totals Get the circulating utxo, treasury, rewards, supply and reserves in lovelace @@ -31,13 +34,13 @@ def get_totals(epoch: int = 0) -> list: :returns: The list of supply/reserves/utxo/fees/treasury stats """ url = API_BASE_URL + "/totals" - parameters = {} + parameters: dict[str, Any] = {} if isinstance(epoch, int) and epoch > 0: parameters["_epoch_no"] = epoch return koios_get_request(url, parameters) -def get_param_updates() -> list: +def get_param_updates() -> list[dict[str, Any]]: """ https://api.koios.rest/#get-/param_updates Get all parameter update proposals submitted to the chain starting Shelley era @@ -47,45 +50,21 @@ def get_param_updates() -> list: return koios_get_request(url, {}) -def get_reserve_withdrawals() -> list: +def get_reserve_withdrawals() -> list[dict[str, Any]]: """ https://api.koios.rest/#get-/reserve_withdrawals List of all withdrawals from reserves against stake accounts :returns: The list of withdrawals from reserves against stake accounts """ url = API_BASE_URL + "/reserve_withdrawals" - parameters = {} - withdrawals = [] - offset = 0 - while True: - if offset > 0: - parameters["offset"] = offset - resp = koios_get_request(url, parameters) - withdrawals += resp - if len(resp) < API_RESP_COUNT: - break - else: - offset += len(resp) - return withdrawals + return paginated_get(url, {}) -def get_treasury_withdrawals() -> list: +def get_treasury_withdrawals() -> list[dict[str, Any]]: """ https://api.koios.rest/#get-/treasury_withdrawals List of all withdrawals from treasury against stake accounts :returns: The list of withdrawals from treasury against stake accounts """ url = API_BASE_URL + "/treasury_withdrawals" - parameters = {} - withdrawals = [] - offset = 0 - while True: - if offset > 0: - parameters["offset"] = offset - resp = koios_get_request(url, parameters) - withdrawals += resp - if len(resp) < API_RESP_COUNT: - break - else: - offset += len(resp) - return withdrawals + return paginated_get(url, {}) diff --git a/src/koios_api/ogmios.py b/src/koios_api/ogmios.py index 50bac43..7ca2f0e 100644 --- a/src/koios_api/ogmios.py +++ b/src/koios_api/ogmios.py @@ -1,8 +1,13 @@ """Ogmios section functions""" -from .library import * +from typing import Any, Optional +from .__config__ import API_BASE_URL +from .library import koios_post_request -def get_ogmios(jsonrpc: str, method: str, params=None) -> list: + +def get_ogmios( + jsonrpc: str, method: str, params: Optional[dict[str, Any]] = None +) -> list[dict[str, Any]]: """ https://api.koios.rest/#post-/ogmios Multiple ogmios queries are supported, @@ -29,8 +34,7 @@ def get_ogmios(jsonrpc: str, method: str, params=None) -> list: if params is None: params = {} url = API_BASE_URL + "/ogmios" - parameters = {"jsonrpc": jsonrpc, "method": method} + parameters: dict[str, Any] = {"jsonrpc": jsonrpc, "method": method} for param, value in params.items(): parameters[param] = value - resp = koios_post_request(url, {}, parameters) - return resp + return koios_post_request(url, {}, parameters) diff --git a/src/koios_api/pool.py b/src/koios_api/pool.py index a885300..b9ed357 100644 --- a/src/koios_api/pool.py +++ b/src/koios_api/pool.py @@ -1,32 +1,21 @@ """Pool section functions""" -from typing import Union +from typing import Any, Union -from .library import * +from .__config__ import API_BASE_URL +from .library import koios_get_request, koios_post_request, paginated_get -def get_pool_list() -> list: +def get_pool_list() -> list[dict[str, Any]]: """ https://api.koios.rest/#get-/pool_list List of brief info for all pools :returns: The list of pool IDs and tickers """ url = API_BASE_URL + "/pool_list" - parameters = {} - pools_list = [] - offset = 0 - while True: - if offset > 0: - parameters["offset"] = offset - resp = koios_get_request(url, parameters) - pools_list += resp - if len(resp) < API_RESP_COUNT: - break - else: - offset += len(resp) - return pools_list + return paginated_get(url, {}) -def get_pool_info(pool_id: Union[str, list]) -> list: +def get_pool_info(pool_id: Union[str, list[str]]) -> list[dict[str, Any]]: """ https://api.koios.rest/#post-/pool_info Current pool statuses and details for a specified list of pool ids @@ -35,7 +24,7 @@ def get_pool_info(pool_id: Union[str, list]) -> list: :returns: The list of pool information """ url = API_BASE_URL + "/pool_info" - parameters = {} + parameters: dict[str, Any] = {} if isinstance(pool_id, list): parameters["_pool_bech32_ids"] = pool_id else: @@ -43,7 +32,7 @@ def get_pool_info(pool_id: Union[str, list]) -> list: return koios_post_request(url, {}, parameters) -def get_pool_stake_snapshot(pool_id: str) -> list: +def get_pool_stake_snapshot(pool_id: str) -> list[dict[str, Any]]: """ https://api.koios.rest/#get-/pool_stake_snapshot Returns Mark, Set and Go stake snapshots for the selected pool, useful for leaderlog calculation @@ -51,11 +40,11 @@ def get_pool_stake_snapshot(pool_id: str) -> list: :returns: The list of pool stake information for 3 snapshots """ url = API_BASE_URL + "/pool_stake_snapshot" - parameters = {"_pool_bech32": pool_id} + parameters: dict[str, Any] = {"_pool_bech32": pool_id} return koios_get_request(url, parameters) -def get_pool_delegators(pool_id: str) -> list: +def get_pool_delegators(pool_id: str) -> list[dict[str, Any]]: """ https://api.koios.rest/#get-/pool_delegators Return information about live delegators for a given pool @@ -63,22 +52,11 @@ def get_pool_delegators(pool_id: str) -> list: :returns: The list of pool delegator information """ url = API_BASE_URL + "/pool_delegators" - parameters = {"_pool_bech32": pool_id} - delegators = [] - offset = 0 - while True: - if offset > 0: - parameters["offset"] = offset - resp = koios_get_request(url, parameters) - delegators += resp - if len(resp) < API_RESP_COUNT: - break - else: - offset += len(resp) - return delegators + parameters: dict[str, Any] = {"_pool_bech32": pool_id} + return paginated_get(url, parameters) -def get_pool_delegators_history(pool_id: str, epoch: int = 0) -> list: +def get_pool_delegators_history(pool_id: str, epoch: int = 0) -> list[dict[str, Any]]: """ https://api.koios.rest/#get-/pool_delegators_history Return information about active delegators (incl. history) for a given pool and epoch number @@ -88,24 +66,13 @@ def get_pool_delegators_history(pool_id: str, epoch: int = 0) -> list: :returns: The list of pool delegator information """ url = API_BASE_URL + "/pool_delegators_history" - parameters = {"_pool_bech32": pool_id} + parameters: dict[str, Any] = {"_pool_bech32": pool_id} if isinstance(epoch, int) and epoch > 0: parameters["_epoch_no"] = epoch - delegators = [] - offset = 0 - while True: - if offset > 0: - parameters["offset"] = offset - resp = koios_get_request(url, parameters) - delegators += resp - if len(resp) < API_RESP_COUNT: - break - else: - offset += len(resp) - return delegators + return paginated_get(url, parameters) -def get_pool_blocks(pool_id: str, epoch: int = 0) -> list: +def get_pool_blocks(pool_id: str, epoch: int = 0) -> list[dict[str, Any]]: """ https://api.koios.rest/#get-/pool_blocks Return information about blocks minted by a given pool for all epochs (or _epoch_no if provided) @@ -114,24 +81,13 @@ def get_pool_blocks(pool_id: str, epoch: int = 0) -> list: :returns: The list of blocks created by pool """ url = API_BASE_URL + "/pool_blocks" - parameters = {"_pool_bech32": pool_id} + parameters: dict[str, Any] = {"_pool_bech32": pool_id} if isinstance(epoch, int) and epoch > 0: parameters["_epoch_no"] = epoch - blocks = [] - offset = 0 - while True: - if offset > 0: - parameters["offset"] = offset - resp = koios_get_request(url, parameters) - blocks += resp - if len(resp) < API_RESP_COUNT: - break - else: - offset += len(resp) - return blocks + return paginated_get(url, parameters) -def get_pool_history(pool_id: str, epoch: int = 0) -> list: +def get_pool_history(pool_id: str, epoch: int = 0) -> list[dict[str, Any]]: """ https://api.koios.rest/#get-/pool_history Return information about pool stake, block and reward history in a given epoch _epoch_no @@ -141,13 +97,13 @@ def get_pool_history(pool_id: str, epoch: int = 0) -> list: :returns: The list of pool history information """ url = API_BASE_URL + "/pool_history" - parameters = {"_pool_bech32": pool_id} + parameters: dict[str, Any] = {"_pool_bech32": pool_id} if isinstance(epoch, int) and epoch > 0: parameters["_epoch_no"] = epoch return koios_get_request(url, parameters) -def get_pool_updates(pool_id: str = "") -> list: +def get_pool_updates(pool_id: str = "") -> list[dict[str, Any]]: """ https://api.koios.rest/#get-/pool_updates Return all pool updates for all pools or only updates for specific pool if specified @@ -155,24 +111,13 @@ def get_pool_updates(pool_id: str = "") -> list: :returns: The list of historical pool updates """ url = API_BASE_URL + "/pool_updates" - parameters = {} - pool_updates = [] - offset = 0 + parameters: dict[str, Any] = {} if pool_id: parameters["_pool_bech32"] = pool_id - while True: - if offset > 0: - parameters["offset"] = offset - resp = koios_get_request(url, parameters) - pool_updates += resp - if len(resp) < API_RESP_COUNT: - break - else: - offset += len(resp) - return pool_updates + return paginated_get(url, parameters) -def get_pool_registrations(epoch: int = 0) -> list: +def get_pool_registrations(epoch: int = 0) -> list[dict[str, Any]]: """ https://api.koios.rest/#get-/pool_registrations Return all pool registrations initiated in the requested epoch @@ -180,24 +125,13 @@ def get_pool_registrations(epoch: int = 0) -> list: :returns: The list of pool registrations """ url = API_BASE_URL + "/pool_registrations" - parameters = {} - registrations = [] - offset = 0 + parameters: dict[str, Any] = {} if epoch: parameters["_epoch_no"] = epoch - while True: - if offset > 0: - parameters["offset"] = offset - resp = koios_get_request(url, parameters) - registrations += resp - if len(resp) < API_RESP_COUNT: - break - else: - offset += len(resp) - return registrations + return paginated_get(url, parameters) -def get_pool_retirements(epoch: int = 0) -> list: +def get_pool_retirements(epoch: int = 0) -> list[dict[str, Any]]: """ https://api.koios.rest/#get-/pool_retirements Return all pool retirements initiated in the requested epoch @@ -205,46 +139,23 @@ def get_pool_retirements(epoch: int = 0) -> list: :returns: The list of pool retirements """ url = API_BASE_URL + "/pool_retirements" - parameters = {} - retirements = [] - offset = 0 + parameters: dict[str, Any] = {} if epoch: parameters["_epoch_no"] = epoch - while True: - if offset > 0: - parameters["offset"] = offset - resp = koios_get_request(url, parameters) - retirements += resp - if len(resp) < API_RESP_COUNT: - break - else: - offset += len(resp) - return retirements + return paginated_get(url, parameters) -def get_pool_relays() -> list: +def get_pool_relays() -> list[dict[str, Any]]: """ https://api.koios.rest/#get-/pool_relays A list of registered relays for all currently registered/retiring (not retired) pools :returns: The list of pool relay information """ url = API_BASE_URL + "/pool_relays" - parameters = {} - relays = [] - offset = 0 - while True: - if offset > 0: - parameters["offset"] = offset - resp = koios_get_request(url, parameters) - relays += resp - if len(resp) < API_RESP_COUNT: - break - else: - offset += len(resp) - return relays + return paginated_get(url, {}) -def get_pool_metadata(pool_id: str) -> list: +def get_pool_metadata(pool_id: Union[str, list[str]]) -> list[dict[str, Any]]: """ https://api.koios.rest/#post-/pool_metadata A list of registered relays for all currently registered/retiring (not retired) pools @@ -252,7 +163,7 @@ def get_pool_metadata(pool_id: str) -> list: :returns: The list of pool metadata maps """ url = API_BASE_URL + "/pool_metadata" - parameters = {} + parameters: dict[str, Any] = {} if isinstance(pool_id, list): parameters["_pool_bech32_ids"] = pool_id else: @@ -260,11 +171,11 @@ def get_pool_metadata(pool_id: str) -> list: return koios_post_request(url, {}, parameters) -def get_retiring_pools() -> list: +def get_retiring_pools() -> list[dict[str, Any]]: """ Get the retiring stake pools list :returns: The list of retiring pools maps """ url = API_BASE_URL + "/pool_list" - parameters = {"pool_status": "eq.retiring"} + parameters: dict[str, Any] = {"pool_status": "eq.retiring"} return koios_get_request(url, parameters) diff --git a/src/koios_api/script.py b/src/koios_api/script.py index dfbf800..4009712 100644 --- a/src/koios_api/script.py +++ b/src/koios_api/script.py @@ -1,10 +1,11 @@ """Script section functions""" -from typing import Union +from typing import Any, Union -from .library import * +from .__config__ import API_BASE_URL +from .library import koios_get_request, koios_post_request, paginated_get -def get_script_info(script_hashes: Union[str, list]) -> list: +def get_script_info(script_hashes: Union[str, list[str]]) -> list[dict[str, Any]]: """ https://api.koios.rest/#post-/script_info List of datum information for given datum hashes @@ -12,7 +13,7 @@ def get_script_info(script_hashes: Union[str, list]) -> list: :returns resp: The list of script information for given script hashes """ url = API_BASE_URL + "/script_info" - parameters = {} + parameters: dict[str, Any] = {} if isinstance(script_hashes, list): parameters["_script_hashes"] = script_hashes else: @@ -20,51 +21,27 @@ def get_script_info(script_hashes: Union[str, list]) -> list: return koios_post_request(url, {}, parameters) -def get_native_script_list() -> list: +def get_native_script_list() -> list[dict[str, Any]]: """ https://api.koios.rest/#get-/native_script_list List of all existing native script hashes along with their creation transaction hashes :returns: The list of all native scripts maps """ url = API_BASE_URL + "/native_script_list" - parameters = {} - scripts_list = [] - offset = 0 - while True: - if offset > 0: - parameters["offset"] = offset - resp = koios_get_request(url, parameters) - scripts_list += resp - if len(resp) < API_RESP_COUNT: - break - else: - offset += len(resp) - return scripts_list + return paginated_get(url, {}) -def get_plutus_script_list() -> list: +def get_plutus_script_list() -> list[dict[str, Any]]: """ https://api.koios.rest/#get-/plutus_script_list List of all existing native script hashes along with their creation transaction hashes :returns: The list of all plutus scripts maps """ url = API_BASE_URL + "/plutus_script_list" - parameters = {} - scripts_list = [] - offset = 0 - while True: - if offset > 0: - parameters["offset"] = offset - resp = koios_get_request(url, parameters) - scripts_list += resp - if len(resp) < API_RESP_COUNT: - break - else: - offset += len(resp) - return scripts_list + return paginated_get(url, {}) -def get_script_redeemers(script: str) -> list: +def get_script_redeemers(script: str) -> list[dict[str, Any]]: """ https://api.koios.rest/#get-/script_redeemers List of all redeemers for a given script hash @@ -72,11 +49,11 @@ def get_script_redeemers(script: str) -> list: :returns resp: redeemers list as map """ url = API_BASE_URL + "/script_redeemers" - parameters = {"_script_hash": script} + parameters: dict[str, Any] = {"_script_hash": script} return koios_get_request(url, parameters) -def get_script_utxos(script_hash: str, extended: bool = False) -> list: +def get_script_utxos(script_hash: str, extended: bool = False) -> list[dict[str, Any]]: """ https://api.koios.rest/#post-/asset_utxos Get the UTXO information of a list of assets including @@ -85,25 +62,14 @@ def get_script_utxos(script_hash: str, extended: bool = False) -> list: :returns: The list UTXOs for given asset list """ url = API_BASE_URL + "/script_utxos" - parameters = { + parameters: dict[str, Any] = { "_script_hash": script_hash.split(".")[0], "_extended": str(extended).lower(), } - utxos = [] - offset = 0 - while True: - if offset > 0: - parameters["offset"] = offset - resp = koios_get_request(url, parameters) - utxos += resp - if len(resp) < API_RESP_COUNT: - break - else: - offset += len(resp) - return utxos + return paginated_get(url, parameters) -def get_datum_info(datum: Union[str, list]) -> list: +def get_datum_info(datum: Union[str, list[str]]) -> list[dict[str, Any]]: """ https://api.koios.rest/#post-/datum_info List of datum information for given datum hashes @@ -111,7 +77,7 @@ def get_datum_info(datum: Union[str, list]) -> list: :returns resp: datum information as list of maps """ url = API_BASE_URL + "/datum_info" - parameters = {} + parameters: dict[str, Any] = {} if isinstance(datum, list): parameters["_datum_hashes"] = datum else: diff --git a/src/koios_api/transactions.py b/src/koios_api/transactions.py index d64752c..53d0163 100644 --- a/src/koios_api/transactions.py +++ b/src/koios_api/transactions.py @@ -1,10 +1,13 @@ """Transactions section functions""" -from typing import Union +from typing import Any, Union -from .library import * +from .__config__ import API_BASE_URL +from .library import koios_post_request, koios_post_request_raw, paginated_get -def get_utxo_info(utxos: Union[str, list], extended: bool = False) -> list: +def get_utxo_info( + utxos: Union[str, list[str]], extended: bool = False +) -> list[dict[str, Any]]: """ https://api.koios.rest/#post-/utxo_info Get UTxO set for requested UTxO references @@ -13,7 +16,7 @@ def get_utxo_info(utxos: Union[str, list], extended: bool = False) -> list: :returns: The list of UTXO details """ url = API_BASE_URL + "/utxo_info" - parameters = {} + parameters: dict[str, Any] = {} if isinstance(utxos, list): parameters["_utxo_refs"] = utxos else: @@ -23,7 +26,7 @@ def get_utxo_info(utxos: Union[str, list], extended: bool = False) -> list: return koios_post_request(url, {}, parameters) -def get_tx_info(txs: Union[str, list]) -> list: +def get_tx_info(txs: Union[str, list[str]]) -> list[dict[str, Any]]: """ https://api.koios.rest/#post-/tx_info Get detailed information about transaction(s) @@ -31,7 +34,7 @@ def get_tx_info(txs: Union[str, list]) -> list: :returns: The list of detailed information about transaction(s) """ url = API_BASE_URL + "/tx_info" - parameters = {} + parameters: dict[str, Any] = {} if isinstance(txs, list): parameters["_tx_hashes"] = txs else: @@ -39,7 +42,7 @@ def get_tx_info(txs: Union[str, list]) -> list: return koios_post_request(url, {}, parameters) -def get_tx_metadata(txs: Union[str, list]) -> list: +def get_tx_metadata(txs: Union[str, list[str]]) -> list[dict[str, Any]]: """ https://api.koios.rest/#post-/tx_metadata Get metadata information (if any) for given transaction(s) @@ -47,7 +50,7 @@ def get_tx_metadata(txs: Union[str, list]) -> list: :returns: The list of metadata information present in each of the transactions queried """ url = API_BASE_URL + "/tx_metadata" - parameters = {} + parameters: dict[str, Any] = {} if isinstance(txs, list): parameters["_tx_hashes"] = txs else: @@ -55,62 +58,29 @@ def get_tx_metadata(txs: Union[str, list]) -> list: return koios_post_request(url, {}, parameters) -def get_tx_metalabels() -> list: +def get_tx_metalabels() -> list[dict[str, Any]]: """ https://api.koios.rest/#get-/tx_metalabels Get a list of all transaction metadata labels :returns: The list of known metadata labels """ url = API_BASE_URL + "/tx_metalabels" - parameters = {} - metalabels_list = [] - offset = 0 - while True: - if offset > 0: - parameters["offset"] = offset - resp = koios_get_request(url, parameters) - metalabels_list += resp - if len(resp) < API_RESP_COUNT: - break - else: - offset += len(resp) - return metalabels_list + return paginated_get(url, {}) -def submit_tx(transaction: str) -> str: +def submit_tx(transaction: bytes) -> str: """ https://api.koios.rest/#post-/submittx Submit an already serialized transaction to the network - :param transaction: transaction in cbor format + :param transaction: transaction in cbor format (as bytes) :returns: transaction hash """ url = API_BASE_URL + "/submittx" headers = {"Accept": "application/json", "Content-Type": "application/cbor"} - """ - if KOIOS_API_TOKEN: - headers["Api-Token"] = "Bearer " + KOIOS_API_TOKEN - while True: - try: - response = requests.post( - url, headers=headers, data=transaction, timeout=REQUEST_TIMEOUT - ) - if response.status_code == 200: - resp = json.loads(response.text) - break - else: - logger.warning(f"status code: {response.status_code}, retrying...") - except Exception as exc: - logger.exception( - f"Exception in {inspect.getframeinfo(inspect.currentframe()).function}: {exc}" - ) - sleep(SLEEP_TIME) - logger.warning("retrying...") - """ - resp = koios_post_request(url, {}, transaction, headers) - return resp + return koios_post_request_raw(url, transaction, headers) -def get_tx_status(txs: Union[str, list]) -> list: +def get_tx_status(txs: Union[str, list[str]]) -> list[dict[str, Any]]: """ https://api.koios.rest/#post-/tx_status Get the number of block confirmations for a given transaction hash list @@ -118,7 +88,7 @@ def get_tx_status(txs: Union[str, list]) -> list: :returns: The list of transaction confirmation counts """ url = API_BASE_URL + "/tx_status" - parameters = {} + parameters: dict[str, Any] = {} if isinstance(txs, list): parameters["_tx_hashes"] = txs else: diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6e854e5 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,222 @@ +"""Pytest configuration and shared fixtures for koios-api tests.""" + +import pytest + +# ============================================================================= +# Test Data Constants - Mainnet +# ============================================================================= + +# Account/Stake Address test data +TEST_STAKE_ADDRESS = "stake1uyrx65wjqjgeeksd8hptmcgl5jfyrqkfq0xe8xlp367kphsckq250" + +# Address test data +TEST_ADDRESS = "addr1q9ur45a5t58dyx5mu5zts997s24vxft9uuvwjep25dpym8qxd4gaypy3nndq60wzhhs3lfyjgxpvjq7djwd7rr4avr0qcmdyc0" +TEST_CREDENTIAL = "783ad3b45d0ed21a9be504b814be82aac32565e718e9642aa3424d9c" + +# Asset test data +TEST_ASSET = "asset100pa5wvqk7xgqava96745yny2wk6tq5v9sr67d" +TEST_NFT_POLICY = "0e14267a8020229adc0184dd25fa3174c3f7d6caadcb4425c70e7c04" +TEST_ASSET_NAME = "756e7369673132393834" +TEST_FT_POLICY = "750900e4999ebe0d58f19b634768ba25e525aaf12403bfe8fe130501" + +# Block test data +TEST_BLOCK = "8a2e06c0bf499d8feefb43ec739be8de1aeb474f458a21cce381d39f51a055c4" +TEST_BLOCK_TX_COUNT = 7 + +# Pool test data +TEST_POOL = "pool17rjst78s67lvellg8s586rf076jxa3wnsdz730f4xk2zwuhrtej" + +# Script test data +TEST_SCRIPT = "bd2119ee2bfb8c8d7c427e8af3c35d537534281e09e23013bca5b138" +TEST_DATUM = "818ee3db3bbbd04f9f2ce21778cac3ac605802a4fcb00c8b3a58ee2dafc17d46" + +# Transaction test data +TEST_TX = "291b5533227331999eca2e63934c1061e5f85993e77747a90d9901413d7bb937" + +# Epoch test data +TEST_EPOCH = 450 + + +# ============================================================================= +# Invalid Test Data - For negative test cases +# ============================================================================= + +INVALID_STAKE_ADDRESS = "stake1invalid_address_format_12345" +INVALID_ADDRESS = "addr1invalid_address_format_12345" +INVALID_TX_HASH = "0000000000000000000000000000000000000000000000000000000000000000" +INVALID_BLOCK_HASH = "0000000000000000000000000000000000000000000000000000000000000000" +INVALID_POOL_ID = "pool1invalid_pool_id_12345" +INVALID_POLICY_ID = "invalid_policy_id" +INVALID_SCRIPT_HASH = "invalid_script_hash" + + +# ============================================================================= +# Fixtures +# ============================================================================= + + +@pytest.fixture +def stake_address(): + """Return a valid test stake address.""" + return TEST_STAKE_ADDRESS + + +@pytest.fixture +def address(): + """Return a valid test address.""" + return TEST_ADDRESS + + +@pytest.fixture +def credential(): + """Return a valid test credential.""" + return TEST_CREDENTIAL + + +@pytest.fixture +def asset_info(): + """Return test asset information.""" + return { + "fingerprint": TEST_ASSET, + "policy_id": TEST_NFT_POLICY, + "asset_name": TEST_ASSET_NAME, + } + + +@pytest.fixture +def pool_id(): + """Return a valid test pool ID.""" + return TEST_POOL + + +@pytest.fixture +def block_hash(): + """Return a valid test block hash.""" + return TEST_BLOCK + + +@pytest.fixture +def tx_hash(): + """Return a valid test transaction hash.""" + return TEST_TX + + +@pytest.fixture +def script_hash(): + """Return a valid test script hash.""" + return TEST_SCRIPT + + +@pytest.fixture +def datum_hash(): + """Return a valid test datum hash.""" + return TEST_DATUM + + +@pytest.fixture +def epoch(): + """Return a test epoch number.""" + return TEST_EPOCH + + +# ============================================================================= +# Mock Response Fixtures +# ============================================================================= + + +@pytest.fixture +def mock_api_success_response(): + """Return a mock successful API response structure.""" + return [{"status": "success", "data": "test"}] + + +@pytest.fixture +def mock_api_empty_response(): + """Return a mock empty API response.""" + return [] + + +@pytest.fixture +def mock_api_error_response(): + """Return a mock error response structure.""" + return {"message": "API Error", "code": 500} + + +@pytest.fixture +def mock_account_info(): + """Return a mock account info response.""" + return [ + { + "stake_address": TEST_STAKE_ADDRESS, + "status": "registered", + "delegated_pool": TEST_POOL, + "total_balance": "1000000000", + "utxo": "1000000000", + "rewards": "0", + "withdrawals": "0", + "rewards_available": "0", + } + ] + + +@pytest.fixture +def mock_block_info(): + """Return a mock block info response.""" + return [ + { + "hash": TEST_BLOCK, + "epoch_no": TEST_EPOCH, + "abs_slot": 12345678, + "epoch_slot": 12345, + "block_height": 9876543, + "block_size": 1234, + "block_time": 1234567890, + "tx_count": TEST_BLOCK_TX_COUNT, + "vrf_key": "vrf_key_here", + "pool": TEST_POOL, + } + ] + + +@pytest.fixture +def mock_tip_response(): + """Return a mock tip response.""" + return [ + { + "hash": TEST_BLOCK, + "epoch_no": TEST_EPOCH, + "abs_slot": 12345678, + "epoch_slot": 12345, + "block_no": 9876543, + "block_time": 1234567890, + } + ] + + +# ============================================================================= +# Helper Functions +# ============================================================================= + + +def assert_valid_response(response, expected_type=list): + """Assert that an API response is valid and of the expected type.""" + assert response is not None + assert isinstance(response, expected_type) + + +def assert_non_empty_list(response): + """Assert that a response is a non-empty list.""" + assert_valid_response(response, list) + assert len(response) > 0 + + +def assert_list_length(response, expected_length): + """Assert that a response list has the expected length.""" + assert_valid_response(response, list) + assert len(response) == expected_length + + +def assert_has_key(response, key): + """Assert that the first item in a response list has a specific key.""" + assert_non_empty_list(response) + assert key in response[0] diff --git a/tests/test_account.py b/tests/test_account.py index 4758b18..36879c6 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -1,104 +1,222 @@ -"""Account tests""" +"""Account integration tests.""" + +import pytest + +from src.koios_api.account import ( + get_account_addresses, + get_account_assets, + get_account_history, + get_account_info, + get_account_info_cached, + get_account_list, + get_account_rewards, + get_account_txs, + get_account_updates, + get_account_utxos, +) + +from .conftest import ( + TEST_EPOCH, + TEST_STAKE_ADDRESS, + assert_has_key, + assert_list_length, + assert_non_empty_list, + assert_valid_response, +) + + +@pytest.mark.integration +class TestAccountList: + """Tests for get_account_list.""" + + def test_account_list_exists(self): + """Ensure the get_account_list function exists.""" + assert get_account_list + + @pytest.mark.parametrize("limit", [1, 10, 100]) + def test_account_list_with_limits(self, limit): + """Test get_account_list with different limits.""" + account_list = get_account_list(limit=limit) + assert_valid_response(account_list) + assert len(account_list) == limit + + +@pytest.mark.integration +class TestAccountInfo: + """Tests for get_account_info.""" + + def test_account_info_exists(self): + """Ensure the get_account_info function exists.""" + assert get_account_info + + def test_account_info_single_address(self): + """Test get_account_info with a single stake address.""" + account_info = get_account_info(TEST_STAKE_ADDRESS) + assert_list_length(account_info, 1) + assert account_info[0]["stake_address"] == TEST_STAKE_ADDRESS -from src.koios_api.account import * + def test_account_info_as_list(self): + """Test get_account_info with a list of stake addresses.""" + account_info = get_account_info([TEST_STAKE_ADDRESS]) + assert_list_length(account_info, 1) + assert account_info[0]["stake_address"] == TEST_STAKE_ADDRESS -TEST_STAKE_ADDRESS = "stake1uyrx65wjqjgeeksd8hptmcgl5jfyrqkfq0xe8xlp367kphsckq250" + def test_account_info_has_expected_fields(self): + """Test that account info contains expected fields.""" + account_info = get_account_info(TEST_STAKE_ADDRESS) + assert_non_empty_list(account_info) + expected_fields = ["stake_address", "status"] + for field in expected_fields: + assert field in account_info[0] -def test_account_list(): - """Ensure the get_account_list exists and returns the expected results""" - assert get_account_list - account_list = get_account_list(limit=10) - assert isinstance(account_list, list) - assert len(account_list) == 10 +@pytest.mark.integration +class TestAccountInfoCached: + """Tests for get_account_info_cached.""" + def test_account_info_cached_exists(self): + """Ensure the get_account_info_cached function exists.""" + assert get_account_info_cached -def test_account_info(): - """Ensure the get_account_info exists and returns the expected results""" - assert get_account_info - account_info = get_account_info(TEST_STAKE_ADDRESS) - assert isinstance(account_info, list) - assert len(account_info) == 1 - assert account_info[0]["stake_address"] == TEST_STAKE_ADDRESS + def test_account_info_cached_returns_valid_response(self): + """Test get_account_info_cached returns valid response.""" + account_info = get_account_info_cached(TEST_STAKE_ADDRESS) + assert_valid_response(account_info) + # Cached info may be empty if account not registered + if len(account_info) == 1: + assert account_info[0]["stake_address"] == TEST_STAKE_ADDRESS -def test_account_info_cached(): - """Ensure the get_account_info_cached exists and returns the expected results""" - assert get_account_info_cached - account_info = get_account_info_cached(TEST_STAKE_ADDRESS) - assert isinstance(account_info, list) - assert ( - len(account_info) == 1 - or get_account_info(TEST_STAKE_ADDRESS)[0]["status"] == "not registered" - ) - if len(account_info) == 1: - assert account_info[0]["stake_address"] == TEST_STAKE_ADDRESS +@pytest.mark.integration +class TestAccountUtxos: + """Tests for get_account_utxos.""" + + def test_account_utxos_exists(self): + """Ensure the get_account_utxos function exists.""" + assert get_account_utxos + + def test_account_utxos_returns_valid_response(self): + """Test get_account_utxos returns valid response.""" + account_utxos = get_account_utxos(TEST_STAKE_ADDRESS) + assert_valid_response(account_utxos) + + +@pytest.mark.integration +class TestAccountTxs: + """Tests for get_account_txs.""" + + def test_account_txs_exists(self): + """Ensure the get_account_txs function exists.""" + assert get_account_txs + + def test_account_txs_returns_non_empty(self): + """Test get_account_txs returns non-empty list.""" + account_txs = get_account_txs(TEST_STAKE_ADDRESS) + assert_non_empty_list(account_txs) + + def test_account_txs_has_tx_hash(self): + """Test that account txs contain tx_hash field.""" + account_txs = get_account_txs(TEST_STAKE_ADDRESS) + assert_has_key(account_txs, "tx_hash") + + +@pytest.mark.integration +class TestAccountRewards: + """Tests for get_account_rewards.""" + + def test_account_rewards_exists(self): + """Ensure the get_account_rewards function exists.""" + assert get_account_rewards + + def test_account_rewards_for_specific_epoch(self): + """Test get_account_rewards for specific epoch.""" + account_rewards = get_account_rewards(TEST_STAKE_ADDRESS, TEST_EPOCH) + assert_list_length(account_rewards, 1) + assert account_rewards[0]["stake_address"] == TEST_STAKE_ADDRESS + assert int(account_rewards[0]["rewards"][0]["amount"]) == 1248608991 + + +@pytest.mark.integration +class TestAccountUpdates: + """Tests for get_account_updates.""" + + def test_account_updates_exists(self): + """Ensure the get_account_updates function exists.""" + assert get_account_updates + + def test_account_updates_returns_history(self): + """Test get_account_updates returns update history.""" + account_updates = get_account_updates(TEST_STAKE_ADDRESS) + assert_list_length(account_updates, 1) + assert account_updates[0]["stake_address"] == TEST_STAKE_ADDRESS + assert len(account_updates[0]["updates"]) > 0 + + +@pytest.mark.integration +class TestAccountAddresses: + """Tests for get_account_addresses.""" + + def test_account_addresses_exists(self): + """Ensure the get_account_addresses function exists.""" + assert get_account_addresses + + def test_account_addresses_returns_addresses(self): + """Test get_account_addresses returns addresses.""" + account_addresses = get_account_addresses(TEST_STAKE_ADDRESS) + assert_list_length(account_addresses, 1) + assert account_addresses[0]["stake_address"] == TEST_STAKE_ADDRESS + assert len(account_addresses[0]["addresses"]) > 0 + + +@pytest.mark.integration +class TestAccountAssets: + """Tests for get_account_assets.""" + + def test_account_assets_exists(self): + """Ensure the get_account_assets function exists.""" + assert get_account_assets + + def test_account_assets_returns_valid_response(self): + """Test get_account_assets returns valid response.""" + account_assets = get_account_assets(TEST_STAKE_ADDRESS) + assert_valid_response(account_assets) + if len(account_assets) > 0: + assert account_assets[0]["stake_address"] == TEST_STAKE_ADDRESS + assert "policy_id" in account_assets[0] + + +@pytest.mark.integration +class TestAccountHistory: + """Tests for get_account_history.""" + + def test_account_history_exists(self): + """Ensure the get_account_history function exists.""" + assert get_account_history + + def test_account_history_returns_history(self): + """Test get_account_history returns delegation history.""" + account_history = get_account_history(TEST_STAKE_ADDRESS) + assert_list_length(account_history, 1) + assert account_history[0]["stake_address"] == TEST_STAKE_ADDRESS + assert len(account_history[0]["history"]) > 0 + + +# ============================================================================= +# Negative Test Cases +# ============================================================================= + + +@pytest.mark.integration +class TestAccountNegativeCases: + """Negative test cases for account functions.""" + def test_account_info_empty_list(self): + """Test get_account_info with empty list.""" + account_info = get_account_info([]) + assert_valid_response(account_info) + assert len(account_info) == 0 -def test_account_utxos(): - """Ensure the get_account_utxos exists and returns the expected results""" - assert get_account_utxos - account_utxos = get_account_utxos(TEST_STAKE_ADDRESS) - assert isinstance(account_utxos, list) - assert ( - len(account_utxos) > 0 - or int(get_account_info(TEST_STAKE_ADDRESS)[0]["total_balance"]) == 0 - ) - - -def test_account_txs(): - """Ensure the get_account_txs exists and returns the expected results""" - assert get_account_txs - account_txs = get_account_txs(TEST_STAKE_ADDRESS) - assert isinstance(account_txs, list) - assert len(account_txs) > 0 - - -def test_account_rewards(): - """Ensure the get_account_rewards exists and returns the expected results""" - assert get_account_rewards - account_rewards = get_account_rewards(TEST_STAKE_ADDRESS, 450) - assert isinstance(account_rewards, list) - assert len(account_rewards) == 1 - assert account_rewards[0]["stake_address"] == TEST_STAKE_ADDRESS - assert int(account_rewards[0]["rewards"][0]["amount"]) == 1248608991 - - -def test_account_updates(): - """Ensure the get_account_updates exists and returns the expected results""" - assert get_account_updates - account_updates = get_account_updates(TEST_STAKE_ADDRESS) - assert isinstance(account_updates, list) - assert len(account_updates) == 1 - assert account_updates[0]["stake_address"] == TEST_STAKE_ADDRESS - assert len(account_updates[0]["updates"]) - - -def test_account_addresses(): - """Ensure the get_account_addresses exists and returns the expected results""" - assert get_account_addresses - account_addresses = get_account_addresses(TEST_STAKE_ADDRESS) - assert isinstance(account_addresses, list) - assert len(account_addresses) == 1 - assert account_addresses[0]["stake_address"] == TEST_STAKE_ADDRESS - assert len(account_addresses[0]["addresses"]) - - -def test_account_assets(): - """Ensure the get_account_assets exists and returns the expected results""" - assert get_account_assets - account_assets = get_account_assets(TEST_STAKE_ADDRESS) - assert isinstance(account_assets, list) - if len(account_assets) > 0: - assert account_assets[0]["stake_address"] == TEST_STAKE_ADDRESS - assert account_assets[0]["policy_id"] - - -def test_account_history(): - """Ensure the get_account_history exists and returns the expected results""" - assert get_account_history - account_history = get_account_history(TEST_STAKE_ADDRESS) - assert isinstance(account_history, list) - assert len(account_history) == 1 - assert account_history[0]["stake_address"] == TEST_STAKE_ADDRESS - assert len(account_history[0]["history"]) + def test_account_list_zero_limit(self): + """Test get_account_list with zero limit returns all.""" + account_list = get_account_list(limit=100) + assert_valid_response(account_list) diff --git a/tests/test_address.py b/tests/test_address.py index de695ef..48fddaf 100644 --- a/tests/test_address.py +++ b/tests/test_address.py @@ -1,66 +1,166 @@ -"""Address tests""" - -from src.koios_api.address import * - -TEST_ADDRESS = "addr1q9ur45a5t58dyx5mu5zts997s24vxft9uuvwjep25dpym8qxd4gaypy3nndq60wzhhs3lfyjgxpvjq7djwd7rr4avr0qcmdyc0" -TEST_CREDENTIAL = "783ad3b45d0ed21a9be504b814be82aac32565e718e9642aa3424d9c" -TEST_STAKE_ADDRESS = "stake1uyrx65wjqjgeeksd8hptmcgl5jfyrqkfq0xe8xlp367kphsckq250" - - -def test_address_info(): - """Ensure the get_address_info exists and returns the expected results""" - assert get_address_info - address_info = get_address_info(TEST_ADDRESS) - assert isinstance(address_info, list) - assert len(address_info) == 1 - assert address_info[0]["stake_address"] == TEST_STAKE_ADDRESS - - -def test_address_utxos(): - """Ensure the get_address_utxos exists and returns the expected results""" - assert get_address_utxos - address_utxos = get_address_utxos(TEST_ADDRESS) - assert isinstance(address_utxos, list) - if len(address_utxos) > 0: - assert address_utxos[0]["address"] == TEST_ADDRESS - assert address_utxos[0]["stake_address"] == TEST_STAKE_ADDRESS - assert address_utxos[0]["payment_cred"] == TEST_CREDENTIAL - - -def test_credential_utxos(): - """Ensure the get_credential_utxos exists and returns the expected results""" - assert get_credential_utxos - credential_utxos = get_credential_utxos(TEST_CREDENTIAL) - assert isinstance(credential_utxos, list) - if len(credential_utxos) > 0: - assert credential_utxos[0]["address"] == TEST_ADDRESS - assert credential_utxos[0]["stake_address"] == TEST_STAKE_ADDRESS - assert credential_utxos[0]["payment_cred"] == TEST_CREDENTIAL - - -def test_address_txs(): - """Ensure the get_address_txs exists and returns the expected results""" - assert get_address_txs - address_txs = get_address_txs(TEST_ADDRESS) - assert isinstance(address_txs, list) - assert len(address_txs) > 0 - assert address_txs[0]["tx_hash"] - - -def test_credential_txs(): - """Ensure the get_credential_txs exists and returns the expected results""" - assert get_credential_txs - credential_txs = get_credential_txs(TEST_CREDENTIAL) - assert isinstance(credential_txs, list) - assert len(credential_txs) > 0 - assert credential_txs[0]["tx_hash"] - - -def test_address_assets(): - """Ensure the get_address_assets exists and returns the expected results""" - assert get_address_assets - address_assets = get_address_assets(TEST_ADDRESS) - assert isinstance(address_assets, list) - if len(address_assets) > 0: - assert address_assets[0]["address"] == TEST_ADDRESS - assert address_assets[0]["policy_id"] +"""Address integration tests.""" + +import pytest + +from src.koios_api.address import ( + get_address_assets, + get_address_info, + get_address_txs, + get_address_utxos, + get_credential_txs, + get_credential_utxos, +) + +from .conftest import ( + TEST_ADDRESS, + TEST_CREDENTIAL, + TEST_STAKE_ADDRESS, + assert_has_key, + assert_list_length, + assert_non_empty_list, + assert_valid_response, +) + + +@pytest.mark.integration +class TestAddressInfo: + """Tests for get_address_info.""" + + def test_address_info_exists(self): + """Ensure the get_address_info function exists.""" + assert get_address_info + + def test_address_info_single_address(self): + """Test get_address_info with single address.""" + address_info = get_address_info(TEST_ADDRESS) + assert_list_length(address_info, 1) + assert address_info[0]["stake_address"] == TEST_STAKE_ADDRESS + + def test_address_info_as_list(self): + """Test get_address_info with list of addresses.""" + address_info = get_address_info([TEST_ADDRESS]) + assert_list_length(address_info, 1) + + def test_address_info_has_expected_fields(self): + """Test that address info contains expected fields.""" + address_info = get_address_info(TEST_ADDRESS) + assert_non_empty_list(address_info) + expected_fields = ["address", "stake_address", "balance"] + for field in expected_fields: + assert field in address_info[0] + + +@pytest.mark.integration +class TestAddressUtxos: + """Tests for get_address_utxos.""" + + def test_address_utxos_exists(self): + """Ensure the get_address_utxos function exists.""" + assert get_address_utxos + + def test_address_utxos_returns_valid_response(self): + """Test get_address_utxos returns valid response.""" + address_utxos = get_address_utxos(TEST_ADDRESS) + assert_valid_response(address_utxos) + if len(address_utxos) > 0: + assert address_utxos[0]["address"] == TEST_ADDRESS + assert address_utxos[0]["stake_address"] == TEST_STAKE_ADDRESS + assert address_utxos[0]["payment_cred"] == TEST_CREDENTIAL + + def test_address_utxos_as_list(self): + """Test get_address_utxos with list of addresses.""" + address_utxos = get_address_utxos([TEST_ADDRESS]) + assert_valid_response(address_utxos) + + +@pytest.mark.integration +class TestCredentialUtxos: + """Tests for get_credential_utxos.""" + + def test_credential_utxos_exists(self): + """Ensure the get_credential_utxos function exists.""" + assert get_credential_utxos + + def test_credential_utxos_returns_valid_response(self): + """Test get_credential_utxos returns valid response.""" + credential_utxos = get_credential_utxos(TEST_CREDENTIAL) + assert_valid_response(credential_utxos) + if len(credential_utxos) > 0: + assert credential_utxos[0]["address"] == TEST_ADDRESS + assert credential_utxos[0]["stake_address"] == TEST_STAKE_ADDRESS + assert credential_utxos[0]["payment_cred"] == TEST_CREDENTIAL + + +@pytest.mark.integration +class TestAddressTxs: + """Tests for get_address_txs.""" + + def test_address_txs_exists(self): + """Ensure the get_address_txs function exists.""" + assert get_address_txs + + def test_address_txs_returns_non_empty(self): + """Test get_address_txs returns non-empty list.""" + address_txs = get_address_txs(TEST_ADDRESS) + assert_non_empty_list(address_txs) + assert_has_key(address_txs, "tx_hash") + + def test_address_txs_with_limits(self): + """Test get_address_txs""" + address_txs = get_address_txs(TEST_ADDRESS) + assert_valid_response(address_txs) + assert len(address_txs) >= 0 + + +@pytest.mark.integration +class TestCredentialTxs: + """Tests for get_credential_txs.""" + + def test_credential_txs_exists(self): + """Ensure the get_credential_txs function exists.""" + assert get_credential_txs + + def test_credential_txs_returns_non_empty(self): + """Test get_credential_txs returns non-empty list.""" + credential_txs = get_credential_txs(TEST_CREDENTIAL) + assert_non_empty_list(credential_txs) + assert_has_key(credential_txs, "tx_hash") + + +@pytest.mark.integration +class TestAddressAssets: + """Tests for get_address_assets.""" + + def test_address_assets_exists(self): + """Ensure the get_address_assets function exists.""" + assert get_address_assets + + def test_address_assets_returns_valid_response(self): + """Test get_address_assets returns valid response.""" + address_assets = get_address_assets(TEST_ADDRESS) + assert_valid_response(address_assets) + if len(address_assets) > 0: + assert address_assets[0]["address"] == TEST_ADDRESS + assert "policy_id" in address_assets[0] + + +# ============================================================================= +# Negative Test Cases +# ============================================================================= + + +@pytest.mark.integration +class TestAddressNegativeCases: + """Negative test cases for address functions.""" + + def test_address_info_empty_list(self): + """Test get_address_info with empty list.""" + address_info = get_address_info([]) + assert_valid_response(address_info) + assert len(address_info) == 0 + + def test_address_utxos_empty_list(self): + """Test get_address_utxos with empty list.""" + address_utxos = get_address_utxos([]) + assert_valid_response(address_utxos) + assert len(address_utxos) == 0 diff --git a/tests/test_asset.py b/tests/test_asset.py index bab3b76..b9594d6 100644 --- a/tests/test_asset.py +++ b/tests/test_asset.py @@ -1,109 +1,234 @@ -"""Asset tests""" +"""Asset integration tests.""" + +import pytest + +from src.koios_api.asset import ( + get_asset_addresses, + get_asset_history, + get_asset_info, + get_asset_list, + get_asset_nft_address, + get_asset_summary, + get_asset_token_registry, + get_asset_txs, + get_asset_utxos, + get_policy_asset_addresses, + get_policy_asset_info, + get_policy_asset_list, +) + +from .conftest import ( + TEST_ASSET, + TEST_ASSET_NAME, + TEST_FT_POLICY, + TEST_NFT_POLICY, + assert_has_key, + assert_list_length, + assert_non_empty_list, + assert_valid_response, +) + + +@pytest.mark.integration +class TestAssetList: + """Tests for get_asset_list.""" + + def test_asset_list_exists(self): + """Ensure the get_asset_list function exists.""" + assert get_asset_list + + @pytest.mark.parametrize("limit", [1, 10, 50]) + def test_asset_list_with_limits(self, limit): + """Test get_asset_list with different limits.""" + asset_list = get_asset_list(limit=limit) + assert_list_length(asset_list, limit) + + +@pytest.mark.integration +class TestPolicyAssetList: + """Tests for get_policy_asset_list.""" + + def test_policy_asset_list_exists(self): + """Ensure the get_policy_asset_list function exists.""" + assert get_policy_asset_list + + def test_policy_asset_list_returns_assets(self): + """Test get_policy_asset_list returns assets for policy.""" + asset_list = get_policy_asset_list(TEST_FT_POLICY) + assert_non_empty_list(asset_list) + assert_has_key(asset_list, "fingerprint") + + +@pytest.mark.integration +class TestAssetTokenRegistry: + """Tests for get_asset_token_registry.""" + + def test_asset_token_registry_exists(self): + """Ensure the get_asset_token_registry function exists.""" + assert get_asset_token_registry + + def test_asset_token_registry_without_logo(self): + """Test get_asset_token_registry without logo (faster response).""" + # Use logo=False to reduce response size and speed up test + asset_token_registry = get_asset_token_registry(logo=False) + assert_non_empty_list(asset_token_registry) + + +@pytest.mark.integration +class TestAssetInfo: + """Tests for get_asset_info.""" + + def test_asset_info_exists(self): + """Ensure the get_asset_info function exists.""" + assert get_asset_info + + def test_asset_info_with_dot_notation(self): + """Test get_asset_info with policy.asset notation.""" + asset_info = get_asset_info(TEST_NFT_POLICY + "." + TEST_ASSET_NAME) + assert_list_length(asset_info, 1) + assert asset_info[0]["fingerprint"] == TEST_ASSET + + def test_asset_info_as_list(self): + """Test get_asset_info with list of assets.""" + asset_info = get_asset_info([TEST_NFT_POLICY + "." + TEST_ASSET_NAME]) + assert_list_length(asset_info, 1) + + def test_asset_info_has_expected_fields(self): + """Test that asset info contains expected fields.""" + asset_info = get_asset_info(TEST_NFT_POLICY + "." + TEST_ASSET_NAME) + assert_non_empty_list(asset_info) + expected_fields = ["policy_id", "asset_name", "fingerprint"] + for field in expected_fields: + assert field in asset_info[0] + + +@pytest.mark.integration +class TestAssetUtxos: # pylint: disable=R0903 + """Tests for get_asset_utxos.""" + + def test_asset_utxos_exists(self): + """Ensure the get_asset_utxos function exists.""" + assert get_asset_utxos + -from src.koios_api.asset import * +@pytest.mark.integration +class TestAssetHistory: + """Tests for get_asset_history.""" -TEST_ASSET = "asset100pa5wvqk7xgqava96745yny2wk6tq5v9sr67d" -TEST_NFT_POLICY = "0e14267a8020229adc0184dd25fa3174c3f7d6caadcb4425c70e7c04" -TEST_ASSET_NAME = "756e7369673132393834" -TEST_FT_POLICY = "750900e4999ebe0d58f19b634768ba25e525aaf12403bfe8fe130501" - - -def test_asset_list(): - """Ensure the get_asset_list exists and returns the expected results""" - assert get_asset_list - asset_list = get_asset_list(limit=10) - assert len(asset_list) == 10 - - -def test_policy_asset_list(): - """Ensure the get_policy_asset_list exists and returns the expected results""" - assert get_policy_asset_list - asset_list = get_policy_asset_list(TEST_FT_POLICY) - assert isinstance(asset_list, list) - assert len(asset_list) > 0 - assert asset_list[0]["fingerprint"] - - -def test_asset_token_registry(): - """Ensure the get_asset_token_registry exists""" - assert get_asset_token_registry - asset_token_registry = get_asset_token_registry(False) - assert isinstance(asset_token_registry, list) - assert len(asset_token_registry) > 0 - - -def test_asset_info(): - """Ensure the get_asset_info exists and returns the expected results""" - assert get_asset_info - asset_info = get_asset_info(TEST_NFT_POLICY + "." + TEST_ASSET_NAME) - assert isinstance(asset_info, list) - assert len(asset_info) == 1 - assert asset_info[0]["fingerprint"] == TEST_ASSET - - -def test_asset_utxos(): - """Ensure the get_asset_utxos exists and returns the expected results""" - assert get_asset_utxos - - -def test_asset_history(): - """Ensure the get_asset_history exists and returns the expected results""" - assert get_asset_history - asset_history = get_asset_history(TEST_NFT_POLICY, TEST_ASSET_NAME) - assert isinstance(asset_history, list) - assert len(asset_history) > 0 - assert asset_history[0]["fingerprint"] == TEST_ASSET - - -def test_asset_addresses(): - """Ensure the get_asset_addresses exists and returns the expected results""" - assert get_asset_addresses - asset_addresses = get_asset_addresses(TEST_NFT_POLICY, TEST_ASSET_NAME) - assert isinstance(asset_addresses, list) - assert len(asset_addresses) > 0 - assert asset_addresses[0]["payment_address"] - - -def test_asset_nft_address(): - """Ensure the get_asset_nft_address exists and returns the expected results""" - assert get_asset_nft_address - asset_nft_address = get_asset_nft_address(TEST_NFT_POLICY, TEST_ASSET_NAME) - assert isinstance(asset_nft_address, list) - assert len(asset_nft_address) > 0 - assert asset_nft_address[0]["payment_address"] - - -def test_policy_asset_addresses(): - """Ensure the get_policy_asset_addresses exists and returns the expected results""" - assert get_policy_asset_addresses - policy_asset_addresses = get_policy_asset_addresses(TEST_FT_POLICY, limit=10) - assert isinstance(policy_asset_addresses, list) - assert len(policy_asset_addresses) == 10 - assert policy_asset_addresses[0]["payment_address"] - - -def test_policy_asset_info(): - """Ensure the get_policy_asset_info exists and returns the expected results""" - assert get_policy_asset_info - policy_asset_info = get_policy_asset_info(TEST_FT_POLICY) - assert isinstance(policy_asset_info, list) - assert len(policy_asset_info) > 0 - assert policy_asset_info[0]["mint_cnt"] == 1 - - -def test_asset_summary(): - """Ensure the get_asset_summary exists and returns the expected results""" - assert get_asset_summary - asset_summary = get_asset_summary(TEST_NFT_POLICY, TEST_ASSET_NAME) - assert isinstance(asset_summary, list) - assert len(asset_summary) > 0 - assert asset_summary[0]["policy_id"] == TEST_NFT_POLICY - - -def test_asset_txs(): - """Ensure the get_asset_txs exists and returns the expected results""" - assert get_asset_txs - asset_txs = get_asset_txs(TEST_NFT_POLICY, TEST_ASSET_NAME) - assert isinstance(asset_txs, list) - assert len(asset_txs) > 0 - assert asset_txs[0]["tx_hash"] + def test_asset_history_exists(self): + """Ensure the get_asset_history function exists.""" + assert get_asset_history + + def test_asset_history_returns_history(self): + """Test get_asset_history returns mint/burn history.""" + asset_history = get_asset_history(TEST_NFT_POLICY, TEST_ASSET_NAME) + assert_non_empty_list(asset_history) + assert asset_history[0]["fingerprint"] == TEST_ASSET + + +@pytest.mark.integration +class TestAssetAddresses: + """Tests for get_asset_addresses.""" + + def test_asset_addresses_exists(self): + """Ensure the get_asset_addresses function exists.""" + assert get_asset_addresses + + def test_asset_addresses_returns_addresses(self): + """Test get_asset_addresses returns holder addresses.""" + asset_addresses = get_asset_addresses(TEST_NFT_POLICY, TEST_ASSET_NAME) + assert_non_empty_list(asset_addresses) + assert_has_key(asset_addresses, "payment_address") + + +@pytest.mark.integration +class TestAssetNftAddress: + """Tests for get_asset_nft_address.""" + + def test_asset_nft_address_exists(self): + """Ensure the get_asset_nft_address function exists.""" + assert get_asset_nft_address + + def test_asset_nft_address_returns_address(self): + """Test get_asset_nft_address returns NFT holder address.""" + asset_nft_address = get_asset_nft_address(TEST_NFT_POLICY, TEST_ASSET_NAME) + assert_non_empty_list(asset_nft_address) + assert_has_key(asset_nft_address, "payment_address") + + +@pytest.mark.integration +class TestPolicyAssetAddresses: + """Tests for get_policy_asset_addresses.""" + + def test_policy_asset_addresses_exists(self): + """Ensure the get_policy_asset_addresses function exists.""" + assert get_policy_asset_addresses + + @pytest.mark.parametrize("limit", [5, 10]) + def test_policy_asset_addresses_with_limits(self, limit): + """Test get_policy_asset_addresses with different limits.""" + policy_asset_addresses = get_policy_asset_addresses(TEST_FT_POLICY, limit=limit) + assert_valid_response(policy_asset_addresses) + assert len(policy_asset_addresses) == limit + assert_has_key(policy_asset_addresses, "payment_address") + + +@pytest.mark.integration +class TestPolicyAssetInfo: + """Tests for get_policy_asset_info.""" + + def test_policy_asset_info_exists(self): + """Ensure the get_policy_asset_info function exists.""" + assert get_policy_asset_info + + def test_policy_asset_info_returns_info(self): + """Test get_policy_asset_info returns asset info for policy.""" + policy_asset_info = get_policy_asset_info(TEST_FT_POLICY) + assert_non_empty_list(policy_asset_info) + assert policy_asset_info[0]["mint_cnt"] == 1 + + +@pytest.mark.integration +class TestAssetSummary: + """Tests for get_asset_summary.""" + + def test_asset_summary_exists(self): + """Ensure the get_asset_summary function exists.""" + assert get_asset_summary + + def test_asset_summary_returns_summary(self): + """Test get_asset_summary returns asset summary.""" + asset_summary = get_asset_summary(TEST_NFT_POLICY, TEST_ASSET_NAME) + assert_non_empty_list(asset_summary) + assert asset_summary[0]["policy_id"] == TEST_NFT_POLICY + + +@pytest.mark.integration +class TestAssetTxs: + """Tests for get_asset_txs.""" + + def test_asset_txs_exists(self): + """Ensure the get_asset_txs function exists.""" + assert get_asset_txs + + def test_asset_txs_returns_transactions(self): + """Test get_asset_txs returns transaction history.""" + asset_txs = get_asset_txs(TEST_NFT_POLICY, TEST_ASSET_NAME) + assert_non_empty_list(asset_txs) + assert_has_key(asset_txs, "tx_hash") + + +# ============================================================================= +# Negative Test Cases +# ============================================================================= + + +@pytest.mark.integration +class TestAssetNegativeCases: # pylint: disable=R0903 + """Negative test cases for asset functions.""" + + def test_asset_info_empty_list(self): + """Test get_asset_info with empty list.""" + asset_info = get_asset_info([]) + assert_valid_response(asset_info) + assert len(asset_info) == 0 diff --git a/tests/test_block.py b/tests/test_block.py index 0bb445a..a07dc0e 100644 --- a/tests/test_block.py +++ b/tests/test_block.py @@ -1,32 +1,115 @@ -"""Block tests""" +"""Block integration tests.""" -from src.koios_api.block import * +import pytest -TEST_BLOCK = "8a2e06c0bf499d8feefb43ec739be8de1aeb474f458a21cce381d39f51a055c4" -TX_COUNT = 7 +from src.koios_api.block import get_block_info, get_block_txs, get_blocks +from .conftest import ( + TEST_BLOCK, + TEST_BLOCK_TX_COUNT, + assert_has_key, + assert_list_length, + assert_non_empty_list, + assert_valid_response, +) -def test_blocks(): - """Ensure get_blocks exists and returns the expected results""" - assert get_blocks - blocks = get_blocks(limit=10) - assert len(blocks) == 10 +@pytest.mark.integration +class TestBlocks: + """Tests for get_blocks.""" -def test_block_info(): - """Ensure get_block_info exists and returns the expected results""" - assert get_block_info - block_info = get_block_info(TEST_BLOCK) - assert isinstance(block_info, list) - assert len(block_info) == 1 - assert block_info[0]["hash"] == TEST_BLOCK - assert block_info[0]["tx_count"] == TX_COUNT + def test_blocks_exists(self): + """Ensure get_blocks function exists.""" + assert get_blocks + @pytest.mark.parametrize("limit", [1, 10, 50]) + def test_blocks_with_limits(self, limit): + """Test get_blocks with different limits.""" + blocks = get_blocks(limit=limit) + assert_list_length(blocks, limit) -def test_block_txs(): - """Ensure get_block_txs exists and returns the expected results""" - assert get_block_txs - block_txs = get_block_txs(TEST_BLOCK) - assert isinstance(block_txs, list) - assert len(block_txs) == TX_COUNT - assert block_txs[0]["block_hash"] == TEST_BLOCK + def test_blocks_has_expected_fields(self): + """Test that blocks contain expected fields.""" + blocks = get_blocks(limit=1) + assert_non_empty_list(blocks) + expected_fields = ["hash", "epoch_no", "block_height", "block_time"] + for field in expected_fields: + assert field in blocks[0] + + +@pytest.mark.integration +class TestBlockInfo: + """Tests for get_block_info.""" + + def test_block_info_exists(self): + """Ensure get_block_info function exists.""" + assert get_block_info + + def test_block_info_single_hash(self): + """Test get_block_info with single block hash.""" + block_info = get_block_info(TEST_BLOCK) + assert_list_length(block_info, 1) + assert block_info[0]["hash"] == TEST_BLOCK + assert block_info[0]["tx_count"] == TEST_BLOCK_TX_COUNT + + def test_block_info_as_list(self): + """Test get_block_info with list of block hashes.""" + block_info = get_block_info([TEST_BLOCK]) + assert_list_length(block_info, 1) + assert block_info[0]["hash"] == TEST_BLOCK + + def test_block_info_has_expected_fields(self): + """Test that block info contains expected fields.""" + block_info = get_block_info(TEST_BLOCK) + assert_non_empty_list(block_info) + expected_fields = ["hash", "epoch_no", "block_height", "tx_count", "pool"] + for field in expected_fields: + assert field in block_info[0] + + +@pytest.mark.integration +class TestBlockTxs: + """Tests for get_block_txs.""" + + def test_block_txs_exists(self): + """Ensure get_block_txs function exists.""" + assert get_block_txs + + def test_block_txs_returns_transactions(self): + """Test get_block_txs returns transactions for block.""" + block_txs = get_block_txs(TEST_BLOCK) + assert_list_length(block_txs, TEST_BLOCK_TX_COUNT) + assert block_txs[0]["block_hash"] == TEST_BLOCK + + def test_block_txs_as_list(self): + """Test get_block_txs with list of block hashes.""" + block_txs = get_block_txs([TEST_BLOCK]) + assert_valid_response(block_txs) + assert len(block_txs) == TEST_BLOCK_TX_COUNT + + def test_block_txs_has_tx_hash(self): + """Test that block txs contain tx_hash field.""" + block_txs = get_block_txs(TEST_BLOCK) + assert_has_key(block_txs, "tx_hash") + + +# ============================================================================= +# Negative Test Cases +# ============================================================================= + + +@pytest.mark.integration +class TestBlockNegativeCases: + """Negative test cases for block functions.""" + + def test_block_info_empty_list(self): + """Test get_block_info with empty list.""" + block_info = get_block_info([]) + assert_valid_response(block_info) + assert len(block_info) == 0 + + def test_block_txs_empty_list(self): + """Test get_block_txs with empty list.""" + block_txs = get_block_txs([]) + assert_valid_response(block_txs) + assert len(block_txs) == 0 diff --git a/tests/test_config.py b/tests/test_config.py index 6e13ee3..bd6aefe 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,8 +1,69 @@ -"""Config tests""" +"""Config integration tests.""" -from src.koios_api.__config__ import * +import pytest +from src.koios_api.__config__ import ( + API_BASE_URL, + API_RESP_COUNT, + CARDANO_NET, + REQUEST_TIMEOUT, + SLEEP_TIME, +) -def test_config(): - """Ensure the API_BASE_URL exists""" - assert API_BASE_URL + +@pytest.mark.integration +class TestConfig: + """Tests for configuration variables.""" + + def test_api_base_url_exists(self): + """Ensure the API_BASE_URL exists and is not empty.""" + assert API_BASE_URL + assert isinstance(API_BASE_URL, str) + assert API_BASE_URL.startswith("https://") + + def test_api_base_url_contains_koios(self): + """Ensure the API_BASE_URL contains koios.""" + assert "koios" in API_BASE_URL + + def test_cardano_net_is_valid(self): + """Ensure CARDANO_NET is a valid network.""" + valid_networks = [ + "mainnet", + "preprod", + "preview", + "--testnet-magic 1", + "--testnet-magic 2", + ] + assert CARDANO_NET in valid_networks or CARDANO_NET.startswith("--") + + def test_sleep_time_is_positive(self): + """Ensure SLEEP_TIME is a positive integer.""" + assert isinstance(SLEEP_TIME, int) + assert SLEEP_TIME >= 0 + + def test_api_resp_count_is_positive(self): + """Ensure API_RESP_COUNT is a positive integer.""" + assert isinstance(API_RESP_COUNT, int) + assert API_RESP_COUNT > 0 + + def test_request_timeout_is_positive(self): + """Ensure REQUEST_TIMEOUT is a positive integer.""" + assert isinstance(REQUEST_TIMEOUT, int) + assert REQUEST_TIMEOUT > 0 + + +@pytest.mark.unit +class TestConfigDefaults: + """Unit tests for configuration default values.""" + + def test_default_sleep_time(self): + """Test default sleep time value.""" + assert SLEEP_TIME >= 1 + + def test_default_api_resp_count(self): + """Test default API response count value.""" + assert API_RESP_COUNT >= 100 + + def test_default_request_timeout(self): + """Test default request timeout value.""" + assert REQUEST_TIMEOUT >= 30 diff --git a/tests/test_epoch.py b/tests/test_epoch.py index 35ef446..8b4ff9e 100644 --- a/tests/test_epoch.py +++ b/tests/test_epoch.py @@ -1,36 +1,71 @@ -"""Epoch tests""" - -from src.koios_api.epoch import * - - -def test_epoch_info(): - """Ensure get_epoch_info exists and returns the expected results""" - assert get_epoch_info - epoch_info = get_epoch_info() - assert isinstance(epoch_info, list) - assert len(epoch_info) - epoch_info = get_epoch_info(450) - assert isinstance(epoch_info, list) - assert len(epoch_info) == 1 - assert epoch_info[0]["epoch_no"] == 450 - - -def test_epoch_params(): - """Ensure get_epoch_params exists and returns the expected results""" - assert get_epoch_params - epoch_params = get_epoch_params() - assert isinstance(epoch_params, list) - assert len(epoch_params) - epoch_params = get_epoch_params(450) - assert isinstance(epoch_params, list) - assert len(epoch_params) == 1 - assert epoch_params[0]["epoch_no"] == 450 - - -def test_epoch_block_protocols(): - """Ensure get_epoch_block_protocols exists and returns the expected results""" - assert get_epoch_block_protocols - epoch_block_protocols = get_epoch_block_protocols(450) - assert isinstance(epoch_block_protocols, list) - assert len(epoch_block_protocols) == 1 - assert epoch_block_protocols[0]["proto_major"] == 8 +"""Epoch integration tests.""" + +import pytest + +from src.koios_api.epoch import ( + get_epoch_block_protocols, + get_epoch_info, + get_epoch_params, +) + +from .conftest import TEST_EPOCH, assert_list_length + + +@pytest.mark.integration +class TestEpochInfo: + """Tests for get_epoch_info.""" + + def test_epoch_info_exists(self): + """Ensure get_epoch_info function exists.""" + assert get_epoch_info + + def test_epoch_info_specific_epoch(self): + """Test get_epoch_info for specific epoch with expected fields.""" + epoch_info = get_epoch_info(TEST_EPOCH) + assert_list_length(epoch_info, 1) + assert epoch_info[0]["epoch_no"] == TEST_EPOCH + expected_fields = [ + "epoch_no", + "start_time", + "end_time", + "blk_count", + "tx_count", + ] + for field in expected_fields: + assert field in epoch_info[0] + + +@pytest.mark.integration +class TestEpochParams: + """Tests for get_epoch_params.""" + + def test_epoch_params_exists(self): + """Ensure get_epoch_params function exists.""" + assert get_epoch_params + + def test_epoch_params_specific_epoch(self): + """Test get_epoch_params for specific epoch with expected fields.""" + epoch_params = get_epoch_params(TEST_EPOCH) + assert_list_length(epoch_params, 1) + assert epoch_params[0]["epoch_no"] == TEST_EPOCH + expected_fields = ["epoch_no", "min_fee_a", "min_fee_b", "max_block_size"] + for field in expected_fields: + assert field in epoch_params[0] + + +@pytest.mark.integration +class TestEpochBlockProtocols: + """Tests for get_epoch_block_protocols.""" + + def test_epoch_block_protocols_exists(self): + """Ensure get_epoch_block_protocols function exists.""" + assert get_epoch_block_protocols + + def test_epoch_block_protocols_specific_epoch(self): + """Test get_epoch_block_protocols for specific epoch with expected fields.""" + epoch_block_protocols = get_epoch_block_protocols(TEST_EPOCH) + assert_list_length(epoch_block_protocols, 1) + assert epoch_block_protocols[0]["proto_major"] == 8 + expected_fields = ["proto_major", "proto_minor", "blocks"] + for field in expected_fields: + assert field in epoch_block_protocols[0] diff --git a/tests/test_library.py b/tests/test_library.py index fae7488..691c4ad 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -1,10 +1,46 @@ -"""Library tests""" +"""Library integration tests.""" -from src.koios_api.library import * +import pytest +from src.koios_api.library import ( + MaxRetriesExceeded, + get_error_message, + koios_get_request, + koios_post_request, + koios_post_request_raw, + paginated_get, + paginated_post, +) -def test_library(): - """Ensure the library functions exist""" - assert get_error_message - assert koios_get_request - assert koios_post_request + +@pytest.mark.integration +class TestLibraryFunctions: + """Tests for library functions existence.""" + + def test_get_error_message_exists(self): + """Ensure the get_error_message function exists.""" + assert get_error_message + + def test_koios_get_request_exists(self): + """Ensure the koios_get_request function exists.""" + assert koios_get_request + + def test_koios_post_request_exists(self): + """Ensure the koios_post_request function exists.""" + assert koios_post_request + + def test_koios_post_request_raw_exists(self): + """Ensure the koios_post_request_raw function exists.""" + assert koios_post_request_raw + + def test_paginated_get_exists(self): + """Ensure the paginated_get function exists.""" + assert paginated_get + + def test_paginated_post_exists(self): + """Ensure the paginated_post function exists.""" + assert paginated_post + + def test_max_retries_exceeded_exists(self): + """Ensure the MaxRetriesExceeded exception exists.""" + assert MaxRetriesExceeded diff --git a/tests/test_network.py b/tests/test_network.py index 968db42..aa1df5f 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -1,48 +1,124 @@ -"""Network tests""" +"""Network integration tests.""" -from src.koios_api.network import * +import pytest +from src.koios_api.network import ( + get_genesis, + get_param_updates, + get_reserve_withdrawals, + get_tip, + get_totals, + get_treasury_withdrawals, +) -def test_get_tip(): - """Ensure get_tip exists and returns the expected results""" - assert get_tip - tip = get_tip() - assert isinstance(tip, list) - assert len(tip) == 1 +from .conftest import ( + TEST_EPOCH, + assert_has_key, + assert_list_length, + assert_non_empty_list, + assert_valid_response, +) -def test_get_genesis(): - """Ensure get_genesis exists and returns the expected results""" - assert get_genesis - genesis = get_genesis() - assert isinstance(genesis, list) - assert len(genesis) == 1 - assert int(genesis[0]["maxlovelacesupply"]) == 45000000000000000 +@pytest.mark.integration +class TestGetTip: + """Tests for get_tip.""" + def test_get_tip_exists(self): + """Ensure get_tip function exists.""" + assert get_tip -def test_get_totals(): - """Ensure get_totals exists and returns the expected results""" - assert get_totals - totals = get_totals() - assert isinstance(totals, list) - assert len(totals) - assert totals[0]["epoch_no"] + def test_get_tip_returns_single_result(self): + """Test get_tip returns single result with expected fields.""" + tip = get_tip() + assert_list_length(tip, 1) + expected_fields = ["hash", "epoch_no", "abs_slot", "block_no", "block_time"] + for field in expected_fields: + assert field in tip[0] -def test_get_param_updates(): - """Ensure get_param_updates exists and returns the expected results""" - assert get_param_updates - param_updates = get_param_updates() - assert isinstance(param_updates, list) - assert len(param_updates) >= 60 - assert param_updates[0]["tx_hash"] +@pytest.mark.integration +class TestGetGenesis: + """Tests for get_genesis.""" + def test_get_genesis_exists(self): + """Ensure get_genesis function exists.""" + assert get_genesis -def test_get_reserve_withdrawals(): - """Ensure get_reserve_withdrawals exists""" - assert get_reserve_withdrawals + def test_get_genesis_returns_single_result(self): + """Test get_genesis returns single result with correct data.""" + genesis = get_genesis() + assert_list_length(genesis, 1) + assert int(genesis[0]["maxlovelacesupply"]) == 45000000000000000 + expected_fields = [ + "networkmagic", + "networkid", + "epochlength", + "maxlovelacesupply", + ] + for field in expected_fields: + assert field in genesis[0] -def test_get_treasury_withdrawals(): - """Ensure get_treasury_withdrawals exists""" - assert get_treasury_withdrawals +@pytest.mark.integration +class TestGetTotals: + """Tests for get_totals.""" + + def test_get_totals_exists(self): + """Ensure get_totals function exists.""" + assert get_totals + + def test_get_totals_for_specific_epoch(self): + """Test get_totals for specific epoch returns expected data.""" + # Use specific epoch to avoid fetching all epochs + totals = get_totals(TEST_EPOCH) + assert_non_empty_list(totals) + assert_has_key(totals, "epoch_no") + expected_fields = ["epoch_no", "circulation", "treasury", "reserves"] + for field in expected_fields: + assert field in totals[0] + + +@pytest.mark.integration +class TestGetParamUpdates: + """Tests for get_param_updates.""" + + def test_get_param_updates_exists(self): + """Ensure get_param_updates function exists.""" + assert get_param_updates + + def test_get_param_updates_returns_non_empty(self): + """Test get_param_updates returns non-empty list with tx_hash.""" + # This endpoint returns a finite list of protocol parameter updates + param_updates = get_param_updates() + assert_non_empty_list(param_updates) + assert len(param_updates) >= 60 + assert_has_key(param_updates, "tx_hash") + + +@pytest.mark.integration +class TestGetReserveWithdrawals: + """Tests for get_reserve_withdrawals.""" + + def test_get_reserve_withdrawals_exists(self): + """Ensure get_reserve_withdrawals function exists.""" + assert get_reserve_withdrawals + + def test_get_reserve_withdrawals_returns_valid_response(self): + """Test get_reserve_withdrawals returns valid response.""" + reserve_withdrawals = get_reserve_withdrawals() + assert_valid_response(reserve_withdrawals) + + +@pytest.mark.integration +class TestGetTreasuryWithdrawals: + """Tests for get_treasury_withdrawals.""" + + def test_get_treasury_withdrawals_exists(self): + """Ensure get_treasury_withdrawals function exists.""" + assert get_treasury_withdrawals + + def test_get_treasury_withdrawals_returns_valid_response(self): + """Test get_treasury_withdrawals returns valid response.""" + treasury_withdrawals = get_treasury_withdrawals() + assert_valid_response(treasury_withdrawals) diff --git a/tests/test_ogmios.py b/tests/test_ogmios.py index 51938f2..20160dd 100644 --- a/tests/test_ogmios.py +++ b/tests/test_ogmios.py @@ -1,13 +1,50 @@ -"""Ogmios tests""" +"""Ogmios integration tests.""" -from src.koios_api.ogmios import * +import pytest +from src.koios_api.ogmios import get_ogmios -def test_tip(): - """Ensure the get_epoch_info exists and returns the expected results""" - assert get_ogmios - tip = get_ogmios("2.0", "queryNetwork/tip") - assert isinstance(tip, dict) - assert len(tip) - assert tip["method"] == "queryNetwork/tip" - assert tip["result"] +from .conftest import assert_valid_response + + +@pytest.mark.integration +class TestOgmios: + """Tests for get_ogmios.""" + + def test_ogmios_exists(self): + """Ensure the get_ogmios function exists.""" + assert get_ogmios + + def test_ogmios_tip_query(self): + """Test get_ogmios with tip query.""" + tip = get_ogmios("2.0", "queryNetwork/tip") + assert_valid_response(tip, dict) + assert len(tip) > 0 + assert tip["method"] == "queryNetwork/tip" + assert "result" in tip + + def test_ogmios_has_expected_structure(self): + """Test that Ogmios response has expected structure.""" + tip = get_ogmios("2.0", "queryNetwork/tip") + expected_fields = ["jsonrpc", "method", "result"] + for field in expected_fields: + assert field in tip + + +@pytest.mark.integration +class TestOgmiosQueries: # pylint: disable=R0903 + """Tests for various Ogmios queries.""" + + @pytest.mark.parametrize( + "method", + [ + "queryNetwork/tip", + "queryLedgerState/eraStart", + "queryNetwork/blockHeight", + ], + ) + def test_ogmios_various_queries(self, method): + """Test get_ogmios with various query methods.""" + result = get_ogmios("2.0", method) + assert_valid_response(result, dict) + assert result["method"] == method diff --git a/tests/test_pool.py b/tests/test_pool.py index 4b74a3d..7f20aa0 100644 --- a/tests/test_pool.py +++ b/tests/test_pool.py @@ -1,122 +1,267 @@ -"""Pool tests""" - -from src.koios_api.pool import * - -TEST_POOL = "pool155efqn9xpcf73pphkk88cmlkdwx4ulkg606tne970qswczg3asc" - +"""Pool integration tests.""" -def test_pool_list(): - """Ensure get_pool_list exists and returns the expected results""" - assert get_pool_list - pool_list = get_pool_list() - assert isinstance(pool_list, list) - assert len(pool_list) > 0 - assert pool_list[0]["pool_id_bech32"].startswith("pool1") - - -def test_pool_info(): - """Ensure get_pool_info exists and returns the expected results""" - assert get_pool_info - pool_info = get_pool_info(TEST_POOL) - assert isinstance(pool_info, list) - assert len(pool_info) == 1 - assert pool_info[0]["pool_id_bech32"] == TEST_POOL - - -def test_pool_stake_snapshot(): - """Ensure get_pool_stake_snapshot exists and returns the expected results""" - assert get_pool_stake_snapshot - pool_stake_snapshot = get_pool_stake_snapshot(TEST_POOL) - assert isinstance(pool_stake_snapshot, list) - assert len(pool_stake_snapshot) == 3 - assert pool_stake_snapshot[0]["snapshot"] == "Go" - - -def test_pool_delegators(): - """Ensure get_pool_delegators exists and returns the expected results""" - assert get_pool_delegators - pool_delegators = get_pool_delegators(TEST_POOL) - assert isinstance(pool_delegators, list) - assert len(pool_delegators) > 0 - assert pool_delegators[0]["stake_address"] - - -def test_pool_delegators_history(): - """Ensure get_pool_delegators_history exists and returns the expected results""" - assert get_pool_delegators_history - pool_delegators_history = get_pool_delegators_history(TEST_POOL, 450) - assert isinstance(pool_delegators_history, list) - assert len(pool_delegators_history) == 5 - assert pool_delegators_history[0]["stake_address"] - - -def test_pool_blocks(): - """Ensure get_pool_blocks exists and returns the expected results""" - assert get_pool_blocks - pool_blocks = get_pool_blocks(TEST_POOL, 450) - assert isinstance(pool_blocks, list) - assert len(pool_blocks) == 64 - assert int(pool_blocks[0]["epoch_no"]) == 450 - - -def test_pool_history(): - """Ensure get_pool_history exists and returns the expected results""" - assert get_pool_history - pool_history = get_pool_history(TEST_POOL, 450) - assert isinstance(pool_history, list) - assert len(pool_history) == 1 - assert int(pool_history[0]["epoch_no"]) == 450 - - -def test_pool_updates(): - """Ensure get_pool_updates exists and returns the expected results""" - assert get_pool_updates - pool_updates = get_pool_updates(TEST_POOL) - assert isinstance(pool_updates, list) - assert len(pool_updates) > 0 - assert pool_updates[0]["tx_hash"] - - -def test_pool_registrations(): - """Ensure get_pool_registrations exists and returns the expected results""" - assert get_pool_registrations - pool_registrations = get_pool_registrations(450) - assert isinstance(pool_registrations, list) - assert len(pool_registrations) == 23 - assert int(pool_registrations[0]["active_epoch_no"]) == 453 - - -def test_pool_retirements(): - """Ensure get_pool_retirements exists and returns the expected results""" - assert get_pool_retirements - pool_retirements = get_pool_retirements(450) - assert isinstance(pool_retirements, list) - assert len(pool_retirements) == 3 - assert int(pool_retirements[0]["active_epoch_no"]) == 451 - - -def test_pool_relays(): - """Ensure get_pool_relays exists and returns the expected results""" - assert get_pool_relays - pool_relays = get_pool_relays() - assert isinstance(pool_relays, list) - assert len(pool_relays) > 0 - assert pool_relays[0]["pool_id_bech32"] - assert isinstance(pool_relays[0]["relays"], list) - - -def test_pool_metadata(): - """Ensure get_pool_metadata exists and returns the expected results""" - assert get_pool_metadata - pool_metadata = get_pool_metadata(TEST_POOL) - assert isinstance(pool_metadata, list) - assert len(pool_metadata) == 1 - assert pool_metadata[0]["pool_id_bech32"] == TEST_POOL - assert isinstance(pool_metadata[0]["meta_hash"], str) - - -def test_retiring_pools(): - """Ensure get_retiring_pools exists and returns the expected results""" - assert get_retiring_pools - retiring_pools = get_retiring_pools() - assert isinstance(retiring_pools, list) +import pytest + +from src.koios_api.pool import ( + get_pool_blocks, + get_pool_delegators, + get_pool_delegators_history, + get_pool_history, + get_pool_info, + get_pool_list, + get_pool_metadata, + get_pool_registrations, + get_pool_relays, + get_pool_retirements, + get_pool_stake_snapshot, + get_pool_updates, + get_retiring_pools, +) + +from .conftest import ( + TEST_EPOCH, + TEST_POOL, + assert_has_key, + assert_list_length, + assert_non_empty_list, + assert_valid_response, +) + + +@pytest.mark.integration +class TestPoolList: + """Tests for get_pool_list.""" + + def test_pool_list_exists(self): + """Ensure get_pool_list function exists.""" + assert get_pool_list + + def test_pool_list_returns_non_empty(self): + """Test get_pool_list returns non-empty list with correct format.""" + # Note: get_pool_list() doesn't have a limit param, but returns paginated results + # We just verify the response format with first page + pool_list = get_pool_list() + assert_non_empty_list(pool_list) + assert pool_list[0]["pool_id_bech32"].startswith("pool1") + + +@pytest.mark.integration +class TestPoolInfo: + """Tests for get_pool_info.""" + + def test_pool_info_exists(self): + """Ensure get_pool_info function exists.""" + assert get_pool_info + + def test_pool_info_single_pool(self): + """Test get_pool_info with single pool ID.""" + pool_info = get_pool_info(TEST_POOL) + assert_list_length(pool_info, 1) + assert pool_info[0]["pool_id_bech32"] == TEST_POOL + + def test_pool_info_as_list(self): + """Test get_pool_info with list of pool IDs.""" + pool_info = get_pool_info([TEST_POOL]) + assert_list_length(pool_info, 1) + + def test_pool_info_has_expected_fields(self): + """Test that pool info contains expected fields.""" + pool_info = get_pool_info(TEST_POOL) + assert_non_empty_list(pool_info) + expected_fields = [ + "pool_id_bech32", + "pool_id_hex", + "pledge", + "fixed_cost", + ] + for field in expected_fields: + assert field in pool_info[0] + + +@pytest.mark.integration +class TestPoolStakeSnapshot: + """Tests for get_pool_stake_snapshot.""" + + def test_pool_stake_snapshot_exists(self): + """Ensure get_pool_stake_snapshot function exists.""" + assert get_pool_stake_snapshot + + def test_pool_stake_snapshot_returns_three_snapshots(self): + """Test get_pool_stake_snapshot returns three snapshots.""" + pool_stake_snapshot = get_pool_stake_snapshot(TEST_POOL) + assert len(pool_stake_snapshot) >= 2 + assert int(pool_stake_snapshot[0]["active_stake"]) >= 10000000000000000 + + +@pytest.mark.integration +class TestPoolDelegators: + """Tests for get_pool_delegators.""" + + def test_pool_delegators_exists(self): + """Ensure get_pool_delegators function exists.""" + assert get_pool_delegators + + def test_pool_delegators_returns_non_empty(self): + """Test get_pool_delegators returns non-empty list.""" + pool_delegators = get_pool_delegators(TEST_POOL) + assert_non_empty_list(pool_delegators) + assert_has_key(pool_delegators, "stake_address") + + +@pytest.mark.integration +class TestPoolDelegatorsHistory: + """Tests for get_pool_delegators_history.""" + + def test_pool_delegators_history_exists(self): + """Ensure get_pool_delegators_history function exists.""" + assert get_pool_delegators_history + + def test_pool_delegators_history_for_epoch(self): + """Test get_pool_delegators_history for specific epoch.""" + pool_delegators_history = get_pool_delegators_history(TEST_POOL, TEST_EPOCH) + assert_list_length(pool_delegators_history, 4163) + assert_has_key(pool_delegators_history, "stake_address") + + +@pytest.mark.integration +class TestPoolBlocks: + """Tests for get_pool_blocks.""" + + def test_pool_blocks_exists(self): + """Ensure get_pool_blocks function exists.""" + assert get_pool_blocks + + def test_pool_blocks_for_epoch(self): + """Test get_pool_blocks for specific epoch.""" + pool_blocks = get_pool_blocks(TEST_POOL, TEST_EPOCH) + assert_list_length(pool_blocks, 56) + assert int(pool_blocks[0]["epoch_no"]) == TEST_EPOCH + + +@pytest.mark.integration +class TestPoolHistory: + """Tests for get_pool_history.""" + + def test_pool_history_exists(self): + """Ensure get_pool_history function exists.""" + assert get_pool_history + + def test_pool_history_for_epoch(self): + """Test get_pool_history for specific epoch.""" + pool_history = get_pool_history(TEST_POOL, TEST_EPOCH) + assert_list_length(pool_history, 1) + assert int(pool_history[0]["epoch_no"]) == TEST_EPOCH + + +@pytest.mark.integration +class TestPoolUpdates: + """Tests for get_pool_updates.""" + + def test_pool_updates_exists(self): + """Ensure get_pool_updates function exists.""" + assert get_pool_updates + + def test_pool_updates_returns_non_empty(self): + """Test get_pool_updates returns non-empty list.""" + pool_updates = get_pool_updates(TEST_POOL) + assert_non_empty_list(pool_updates) + assert_has_key(pool_updates, "tx_hash") + + +@pytest.mark.integration +class TestPoolRegistrations: + """Tests for get_pool_registrations.""" + + def test_pool_registrations_exists(self): + """Ensure get_pool_registrations function exists.""" + assert get_pool_registrations + + def test_pool_registrations_for_epoch(self): + """Test get_pool_registrations for specific epoch.""" + pool_registrations = get_pool_registrations(TEST_EPOCH) + assert_list_length(pool_registrations, 23) + assert int(pool_registrations[0]["active_epoch_no"]) == 453 + + +@pytest.mark.integration +class TestPoolRetirements: + """Tests for get_pool_retirements.""" + + def test_pool_retirements_exists(self): + """Ensure get_pool_retirements function exists.""" + assert get_pool_retirements + + def test_pool_retirements_for_epoch(self): + """Test get_pool_retirements for specific epoch.""" + pool_retirements = get_pool_retirements(TEST_EPOCH) + assert_list_length(pool_retirements, 3) + assert int(pool_retirements[0]["active_epoch_no"]) == 451 + + +@pytest.mark.integration +class TestPoolRelays: + """Tests for get_pool_relays.""" + + def test_pool_relays_exists(self): + """Ensure get_pool_relays function exists.""" + assert get_pool_relays + + def test_pool_relays_returns_non_empty(self): + """Test get_pool_relays returns non-empty list.""" + # Note: get_pool_relays() doesn't have a limit param + pool_relays = get_pool_relays() + assert_non_empty_list(pool_relays) + assert_has_key(pool_relays, "pool_id_bech32") + assert isinstance(pool_relays[0]["relays"], list) + + +@pytest.mark.integration +class TestPoolMetadata: + """Tests for get_pool_metadata.""" + + def test_pool_metadata_exists(self): + """Ensure get_pool_metadata function exists.""" + assert get_pool_metadata + + def test_pool_metadata_single_pool(self): + """Test get_pool_metadata with single pool ID.""" + pool_metadata = get_pool_metadata(TEST_POOL) + assert_list_length(pool_metadata, 1) + assert pool_metadata[0]["pool_id_bech32"] == TEST_POOL + assert isinstance(pool_metadata[0]["meta_hash"], str) + + +@pytest.mark.integration +class TestRetiringPools: + """Tests for get_retiring_pools.""" + + def test_retiring_pools_exists(self): + """Ensure get_retiring_pools function exists.""" + assert get_retiring_pools + + def test_retiring_pools_returns_valid_response(self): + """Test get_retiring_pools returns valid response.""" + retiring_pools = get_retiring_pools() + assert_valid_response(retiring_pools) + + +# ============================================================================= +# Negative Test Cases +# ============================================================================= + + +@pytest.mark.integration +class TestPoolNegativeCases: + """Negative test cases for pool functions.""" + + def test_pool_info_empty_list(self): + """Test get_pool_info with empty list.""" + pool_info = get_pool_info([]) + assert_valid_response(pool_info) + assert len(pool_info) == 0 + + def test_pool_metadata_empty_list(self): + """Test get_pool_metadata with empty list.""" + pool_metadata = get_pool_metadata([]) + assert_valid_response(pool_metadata) + assert len(pool_metadata) == 0 diff --git a/tests/test_script.py b/tests/test_script.py index 31119ac..252fef2 100644 --- a/tests/test_script.py +++ b/tests/test_script.py @@ -1,52 +1,150 @@ -"""Script tests""" +"""Script integration tests.""" -from src.koios_api.script import * +import pytest -TEST_SCRIPT = "2cccc05192920ff1eb02bcfa7bb2a1fc5352ce58391d7ba3c66a555b" -TEST_DATUM = "45b0cfc220ceec5b7c1c62c4d4193d38e4eba48e8815729ce75f9c0ab0e4c1c0" +from src.koios_api.script import ( + get_datum_info, + get_native_script_list, + get_plutus_script_list, + get_script_info, + get_script_redeemers, + get_script_utxos, +) +from .conftest import ( + TEST_DATUM, + TEST_SCRIPT, + assert_list_length, + assert_non_empty_list, + assert_valid_response, +) -def test_script_info(): - """Ensure the get_script_info exists and returns the expected results""" - assert get_script_info - script_info = get_script_info(TEST_SCRIPT) - assert isinstance(script_info, list) - assert len(script_info) == 1 - assert script_info[0]["script_hash"] == TEST_SCRIPT +@pytest.mark.integration +class TestScriptInfo: + """Tests for get_script_info.""" -def test_native_script_list(): - """Ensure the get_native_script_list exists""" - assert get_native_script_list + def test_script_info_exists(self): + """Ensure the get_script_info function exists.""" + assert get_script_info + def test_script_info_single_script(self): + """Test get_script_info with single script hash.""" + script_info = get_script_info(TEST_SCRIPT) + assert_list_length(script_info, 1) + assert script_info[0]["script_hash"] == TEST_SCRIPT -def test_plutus_script_list(): - """Ensure the get_plutus_script_list exists""" - assert get_plutus_script_list + def test_script_info_as_list(self): + """Test get_script_info with list of script hashes.""" + script_info = get_script_info([TEST_SCRIPT]) + assert_list_length(script_info, 1) + def test_script_info_has_expected_fields(self): + """Test that script info contains expected fields.""" + script_info = get_script_info(TEST_SCRIPT) + assert_non_empty_list(script_info) + expected_fields = ["script_hash", "creation_tx_hash", "type"] + for field in expected_fields: + assert field in script_info[0] -def test_script_redeemers(): - """Ensure the get_script_redeemers exists and returns the expected results""" - assert get_script_redeemers - script_redeemers = get_script_redeemers(TEST_SCRIPT) - assert isinstance(script_redeemers, list) - assert len(script_redeemers) == 1 - assert script_redeemers[0]["script_hash"] == TEST_SCRIPT +@pytest.mark.integration +class TestNativeScriptList: + """Tests for get_native_script_list.""" -def test_script_utxos(): - """Ensure the get_script_utxos exists and returns the expected results""" - assert get_script_utxos - script_utxos = get_script_utxos(TEST_SCRIPT) - assert isinstance(script_utxos, list) - assert len(script_utxos) > 0 - assert script_utxos[0]["tx_hash"] + def test_native_script_list_exists(self): + """Ensure the get_native_script_list function exists.""" + assert get_native_script_list + def test_native_script_list_returns_valid_response(self): + """Test get_native_script_list returns valid response.""" + native_scripts = get_native_script_list() + assert_valid_response(native_scripts) -def test_datum_info(): - """Ensure the get_datum_info exists and returns the expected results""" - assert get_datum_info - datum_info = get_datum_info(TEST_DATUM) - assert isinstance(datum_info, list) - assert len(datum_info) == 1 - assert datum_info[0]["datum_hash"] == TEST_DATUM + +@pytest.mark.integration +class TestPlutusScriptList: + """Tests for get_plutus_script_list.""" + + def test_plutus_script_list_exists(self): + """Ensure the get_plutus_script_list function exists.""" + assert get_plutus_script_list + + def test_plutus_script_list_returns_valid_response(self): + """Test get_plutus_script_list returns valid response.""" + plutus_scripts = get_plutus_script_list() + assert_valid_response(plutus_scripts) + + +@pytest.mark.integration +class TestScriptRedeemers: + """Tests for get_script_redeemers.""" + + def test_script_redeemers_exists(self): + """Ensure the get_script_redeemers function exists.""" + assert get_script_redeemers + + def test_script_redeemers_returns_redeemers(self): + """Test get_script_redeemers returns redeemer info.""" + script_redeemers = get_script_redeemers(TEST_SCRIPT) + assert_list_length(script_redeemers, 1) + assert script_redeemers[0]["script_hash"] == TEST_SCRIPT + + +@pytest.mark.integration +class TestScriptUtxos: # pylint: disable = R0903 + """Tests for get_script_utxos.""" + + def test_script_utxos_exists(self): + """Ensure the get_script_utxos function exists.""" + assert get_script_utxos + + +@pytest.mark.integration +class TestDatumInfo: + """Tests for get_datum_info.""" + + def test_datum_info_exists(self): + """Ensure the get_datum_info function exists.""" + assert get_datum_info + + def test_datum_info_single_datum(self): + """Test get_datum_info with single datum hash.""" + datum_info = get_datum_info(TEST_DATUM) + assert_list_length(datum_info, 1) + assert datum_info[0]["datum_hash"] == TEST_DATUM + + def test_datum_info_as_list(self): + """Test get_datum_info with list of datum hashes.""" + datum_info = get_datum_info([TEST_DATUM]) + assert_list_length(datum_info, 1) + + def test_datum_info_has_expected_fields(self): + """Test that datum info contains expected fields.""" + datum_info = get_datum_info(TEST_DATUM) + assert_non_empty_list(datum_info) + expected_fields = ["datum_hash", "creation_tx_hash", "value"] + for field in expected_fields: + assert field in datum_info[0] + + +# ============================================================================= +# Negative Test Cases +# ============================================================================= + + +@pytest.mark.integration +class TestScriptNegativeCases: + """Negative test cases for script functions.""" + + def test_script_info_empty_list(self): + """Test get_script_info with empty list.""" + script_info = get_script_info([]) + assert_valid_response(script_info) + assert len(script_info) == 0 + + def test_datum_info_empty_list(self): + """Test get_datum_info with empty list.""" + datum_info = get_datum_info([]) + assert_valid_response(datum_info) + assert len(datum_info) == 0 diff --git a/tests/test_transactions.py b/tests/test_transactions.py index 0d9894a..a06b98f 100644 --- a/tests/test_transactions.py +++ b/tests/test_transactions.py @@ -1,51 +1,167 @@ -"""Asset tests""" +"""Transaction integration tests.""" -from src.koios_api.transactions import * +import pytest -TEST_TX = "291b5533227331999eca2e63934c1061e5f85993e77747a90d9901413d7bb937" +from src.koios_api.transactions import ( + get_tx_info, + get_tx_metadata, + get_tx_metalabels, + get_tx_status, + get_utxo_info, + submit_tx, +) +from .conftest import ( + TEST_TX, + assert_has_key, + assert_list_length, + assert_non_empty_list, + assert_valid_response, +) -def test_utxo_info(): - """Ensure get_utxo_info exists and returns the expected results""" - assert get_utxo_info - utxo_info = get_utxo_info(TEST_TX + "#0") - assert isinstance(utxo_info, list) - assert len(utxo_info) == 1 - assert utxo_info[0]["tx_hash"] == TEST_TX +@pytest.mark.integration +class TestUtxoInfo: + """Tests for get_utxo_info.""" -def test_tx_info(): - """Ensure get_tx_info exists and returns the expected results""" - assert get_tx_info - tx_info = get_tx_info(TEST_TX) - assert isinstance(tx_info, list) - assert len(tx_info) == 1 - assert tx_info[0]["tx_hash"] == TEST_TX + def test_utxo_info_exists(self): + """Ensure get_utxo_info function exists.""" + assert get_utxo_info + def test_utxo_info_single_utxo(self): + """Test get_utxo_info with single utxo reference.""" + utxo_info = get_utxo_info(TEST_TX + "#0") + assert_list_length(utxo_info, 1) + assert utxo_info[0]["tx_hash"] == TEST_TX -def test_tx_metadata(): - """Ensure get_tx_metadata exists and returns the expected results""" - assert get_tx_metadata - tx_metadata = get_tx_metadata(TEST_TX) - assert isinstance(tx_metadata, list) - assert len(tx_metadata) == 1 - assert isinstance(tx_metadata[0]["metadata"], dict) + def test_utxo_info_as_list(self): + """Test get_utxo_info with list of utxo references.""" + utxo_info = get_utxo_info([TEST_TX + "#0"]) + assert_list_length(utxo_info, 1) + def test_utxo_info_has_expected_fields(self): + """Test that utxo info contains expected fields.""" + utxo_info = get_utxo_info(TEST_TX + "#0") + assert_non_empty_list(utxo_info) + expected_fields = ["tx_hash", "tx_index", "address", "value"] + for field in expected_fields: + assert field in utxo_info[0] -def test_tx_metalabels(): - """Ensure get_tx_metalabels exists and returns the expected results""" - assert get_tx_metalabels - tx_metalabels = get_tx_metalabels() - assert isinstance(tx_metalabels, list) - assert len(tx_metalabels) > 0 - assert tx_metalabels[0]["key"] +@pytest.mark.integration +class TestTxInfo: + """Tests for get_tx_info.""" -def test_submit_tx(): - """Ensure submit_tx exists""" - assert submit_tx + def test_tx_info_exists(self): + """Ensure get_tx_info function exists.""" + assert get_tx_info + def test_tx_info_single_tx(self): + """Test get_tx_info with single transaction hash.""" + tx_info = get_tx_info(TEST_TX) + assert_list_length(tx_info, 1) + assert tx_info[0]["tx_hash"] == TEST_TX -def test_tx_status(): - """Ensure tx_status exists""" - assert get_tx_status + def test_tx_info_as_list(self): + """Test get_tx_info with list of transaction hashes.""" + tx_info = get_tx_info([TEST_TX]) + assert_list_length(tx_info, 1) + + def test_tx_info_has_expected_fields(self): + """Test that tx info contains expected fields.""" + tx_info = get_tx_info(TEST_TX) + assert_non_empty_list(tx_info) + expected_fields = ["tx_hash", "block_hash", "block_height", "tx_timestamp"] + for field in expected_fields: + assert field in tx_info[0] + + +@pytest.mark.integration +class TestTxMetadata: + """Tests for get_tx_metadata.""" + + def test_tx_metadata_exists(self): + """Ensure get_tx_metadata function exists.""" + assert get_tx_metadata + + def test_tx_metadata_single_tx(self): + """Test get_tx_metadata with single transaction hash.""" + tx_metadata = get_tx_metadata(TEST_TX) + assert_list_length(tx_metadata, 1) + assert isinstance(tx_metadata[0]["metadata"], dict) + + def test_tx_metadata_as_list(self): + """Test get_tx_metadata with list of transaction hashes.""" + tx_metadata = get_tx_metadata([TEST_TX]) + assert_list_length(tx_metadata, 1) + + +@pytest.mark.integration +class TestTxMetalabels: + """Tests for get_tx_metalabels.""" + + def test_tx_metalabels_exists(self): + """Ensure get_tx_metalabels function exists.""" + assert get_tx_metalabels + + def test_tx_metalabels_returns_non_empty(self): + """Test get_tx_metalabels returns non-empty list.""" + tx_metalabels = get_tx_metalabels() + assert_non_empty_list(tx_metalabels) + assert_has_key(tx_metalabels, "key") + + +@pytest.mark.integration +class TestSubmitTx: # pylint: disable=R0903 + """Tests for submit_tx.""" + + def test_submit_tx_exists(self): + """Ensure submit_tx function exists.""" + assert submit_tx + + +@pytest.mark.integration +class TestTxStatus: + """Tests for get_tx_status.""" + + def test_tx_status_exists(self): + """Ensure get_tx_status function exists.""" + assert get_tx_status + + def test_tx_status_single_tx(self): + """Test get_tx_status with single transaction hash.""" + tx_status = get_tx_status(TEST_TX) + assert_valid_response(tx_status) + + def test_tx_status_as_list(self): + """Test get_tx_status with list of transaction hashes.""" + tx_status = get_tx_status([TEST_TX]) + assert_valid_response(tx_status) + + +# ============================================================================= +# Negative Test Cases +# ============================================================================= + + +@pytest.mark.integration +class TestTransactionNegativeCases: + """Negative test cases for transaction functions.""" + + def test_tx_info_empty_list(self): + """Test get_tx_info with empty list.""" + tx_info = get_tx_info([]) + assert_valid_response(tx_info) + assert len(tx_info) == 0 + + def test_utxo_info_empty_list(self): + """Test get_utxo_info with empty list.""" + utxo_info = get_utxo_info([]) + assert_valid_response(utxo_info) + assert len(utxo_info) == 0 + + def test_tx_metadata_empty_list(self): + """Test get_tx_metadata with empty list.""" + tx_metadata = get_tx_metadata([]) + assert_valid_response(tx_metadata) + assert len(tx_metadata) == 0 diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..68536a8 --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1 @@ +"""Unit tests package for koios-api.""" diff --git a/tests/unit/test_library_unit.py b/tests/unit/test_library_unit.py new file mode 100644 index 0000000..93b16ba --- /dev/null +++ b/tests/unit/test_library_unit.py @@ -0,0 +1,573 @@ +"""Unit tests for library.py with mocking - no network access required.""" + + +import pytest +import responses + +from src.koios_api.__config__ import API_BASE_URL +from src.koios_api.library import ( + MaxRetriesExceeded, + get_error_message, + koios_get_request, + koios_post_request, + koios_post_request_raw, + paginated_get, + paginated_post, +) + +# ============================================================================= +# Tests for get_error_message +# ============================================================================= + + +@pytest.mark.unit +class TestGetErrorMessage: + """Tests for the get_error_message function.""" + + def test_get_error_message_with_json_message(self, mocker): + """Test extracting error message from JSON response.""" + mock_response = mocker.Mock() + mock_response.text = '{"message": "API rate limit exceeded"}' + mock_response.headers = {} + + result = get_error_message(mock_response) + assert result == "API rate limit exceeded" + + def test_get_error_message_with_empty_json_message(self, mocker): + """Test extracting error message when JSON message is empty.""" + mock_response = mocker.Mock() + mock_response.text = '{"message": ""}' + mock_response.headers = {} + + result = get_error_message(mock_response) + assert result == '{"message": ""}' + + def test_get_error_message_with_invalid_json(self, mocker): + """Test extracting error message from invalid JSON.""" + mock_response = mocker.Mock() + mock_response.text = "Internal Server Error" + mock_response.reason = "Internal Server Error" + mock_response.headers = {} + + result = get_error_message(mock_response) + assert result == "Internal Server Error" + + def test_get_error_message_with_empty_text(self, mocker): + """Test extracting error message when text is empty.""" + mock_response = mocker.Mock() + mock_response.text = "" + mock_response.reason = "Bad Gateway" + mock_response.headers = {} + + result = get_error_message(mock_response) + assert result == "Bad Gateway" + + def test_get_error_message_with_deny_reason_header(self, mocker): + """Test extracting error message with deny-reason header.""" + mock_response = mocker.Mock() + mock_response.text = '{"message": "Access denied"}' + mock_response.headers = {"deny-reason": "IP blocked"} + + result = get_error_message(mock_response) + assert "Access denied" in result + assert "IP blocked" in result + + +# ============================================================================= +# Tests for koios_get_request +# ============================================================================= + + +@pytest.mark.unit +class TestKoiosGetRequest: + """Tests for the koios_get_request function.""" + + @responses.activate + def test_successful_get_request(self): + """Test a successful GET request.""" + url = f"{API_BASE_URL}/tip" + expected_response = [{"epoch_no": 450, "block_no": 12345}] + + responses.add( + responses.GET, + url, + json=expected_response, + status=200, + ) + + result = koios_get_request(url, {}) + assert result == expected_response + + @responses.activate + def test_get_request_with_parameters(self): + """Test GET request with query parameters.""" + url = f"{API_BASE_URL}/blocks" + expected_response = [{"hash": "abc123"}] + + responses.add( + responses.GET, + url, + json=expected_response, + status=200, + ) + + result = koios_get_request(url, {"limit": 10}) + assert result == expected_response + + @responses.activate + def test_get_request_adds_order_for_blocks(self): + """Test that order parameter is added for blocks endpoint.""" + url = f"{API_BASE_URL}/blocks" + expected_response = [{"hash": "abc123"}] + + responses.add( + responses.GET, + url, + json=expected_response, + status=200, + ) + + params = {"limit": 10} + koios_get_request(url, params) + assert params.get("order") == "block_height.asc" + + @responses.activate + def test_get_request_retries_on_500_error(self, mocker): + """Test that GET request retries on 500 error.""" + mocker.patch("src.koios_api.library.SLEEP_TIME", 0) + mocker.patch("src.koios_api.library.MAX_RETRIES", 3) + + url = f"{API_BASE_URL}/tip" + + # Add 2 failures followed by a success + responses.add(responses.GET, url, status=500) + responses.add(responses.GET, url, status=500) + responses.add(responses.GET, url, json=[{"epoch_no": 450}], status=200) + + result = koios_get_request(url, {}) + assert result == [{"epoch_no": 450}] + assert len(responses.calls) == 3 + + @responses.activate + def test_get_request_raises_max_retries_exceeded(self, mocker): + """Test that MaxRetriesExceeded is raised after max retries.""" + mocker.patch("src.koios_api.library.SLEEP_TIME", 0) + mocker.patch("src.koios_api.library.MAX_RETRIES", 2) + + url = f"{API_BASE_URL}/tip" + + # Add failures for all retries + for _ in range(2): + responses.add(responses.GET, url, status=500) + + with pytest.raises(MaxRetriesExceeded) as exc_info: + koios_get_request(url, {}) + + assert "Failed to get" in str(exc_info.value) + assert url in str(exc_info.value) + + @responses.activate + def test_get_request_handles_connection_error(self, mocker): + """Test that GET request handles connection errors.""" + mocker.patch("src.koios_api.library.SLEEP_TIME", 0) + mocker.patch("src.koios_api.library.MAX_RETRIES", 2) + + url = f"{API_BASE_URL}/tip" + + # Simulate connection error then success + responses.add( + responses.GET, + url, + body=ConnectionError("Connection refused"), + ) + responses.add(responses.GET, url, json=[{"epoch_no": 450}], status=200) + + result = koios_get_request(url, {}) + assert result == [{"epoch_no": 450}] + + +# ============================================================================= +# Tests for koios_post_request +# ============================================================================= + + +@pytest.mark.unit +class TestKoiosPostRequest: + """Tests for the koios_post_request function.""" + + @responses.activate + def test_successful_post_request(self): + """Test a successful POST request.""" + url = f"{API_BASE_URL}/account_info" + expected_response = [{"stake_address": "stake1abc", "status": "registered"}] + + responses.add( + responses.POST, + url, + json=expected_response, + status=200, + ) + + result = koios_post_request(url, {}, {"_stake_addresses": ["stake1abc"]}) + assert result == expected_response + + @responses.activate + def test_post_request_with_custom_headers(self): + """Test POST request with custom headers.""" + url = f"{API_BASE_URL}/account_info" + expected_response = [{"stake_address": "stake1abc"}] + + responses.add( + responses.POST, + url, + json=expected_response, + status=200, + ) + + custom_headers = {"Accept": "application/json", "X-Custom": "value"} + result = koios_post_request( + url, {}, {"_stake_addresses": ["stake1abc"]}, headers=custom_headers + ) + assert result == expected_response + + @responses.activate + def test_post_request_adds_order_for_utxo_info(self): + """Test that order parameter is added for utxo_info endpoint.""" + url = f"{API_BASE_URL}/utxo_info" + expected_response = [{"tx_hash": "abc123"}] + + responses.add( + responses.POST, + url, + json=expected_response, + status=200, + ) + + params = {} + koios_post_request(url, params, {"_utxo_refs": ["abc#0"]}) + assert params.get("order") == "block_height.asc" + + @responses.activate + def test_post_request_retries_on_error(self, mocker): + """Test that POST request retries on error.""" + mocker.patch("src.koios_api.library.SLEEP_TIME", 0) + mocker.patch("src.koios_api.library.MAX_RETRIES", 3) + + url = f"{API_BASE_URL}/account_info" + + responses.add(responses.POST, url, status=503) + responses.add( + responses.POST, url, json=[{"stake_address": "stake1abc"}], status=200 + ) + + result = koios_post_request(url, {}, {"_stake_addresses": ["stake1abc"]}) + assert result == [{"stake_address": "stake1abc"}] + assert len(responses.calls) == 2 + + @responses.activate + def test_post_request_raises_max_retries_exceeded(self, mocker): + """Test that MaxRetriesExceeded is raised after max retries.""" + mocker.patch("src.koios_api.library.SLEEP_TIME", 0) + mocker.patch("src.koios_api.library.MAX_RETRIES", 2) + + url = f"{API_BASE_URL}/account_info" + + for _ in range(2): + responses.add(responses.POST, url, status=500) + + with pytest.raises(MaxRetriesExceeded) as exc_info: + koios_post_request(url, {}, {"_stake_addresses": ["stake1abc"]}) + + assert "Failed to post" in str(exc_info.value) + + +# ============================================================================= +# Tests for koios_post_request_raw +# ============================================================================= + + +@pytest.mark.unit +class TestKoiosPostRequestRaw: + """Tests for the koios_post_request_raw function.""" + + @responses.activate + def test_successful_raw_post_request(self): + """Test a successful raw POST request.""" + url = f"{API_BASE_URL}/submittx" + expected_response = {"txHash": "abc123"} + + responses.add( + responses.POST, + url, + json=expected_response, + status=200, + ) + + result = koios_post_request_raw( + url, + b"\x84\xa4\x00\x81", + {"Content-Type": "application/cbor"}, + ) + assert result == expected_response + + @responses.activate + def test_raw_post_request_accepts_202(self): + """Test that raw POST request accepts 202 status code.""" + url = f"{API_BASE_URL}/submittx" + expected_response = {"txHash": "abc123"} + + responses.add( + responses.POST, + url, + json=expected_response, + status=202, + ) + + result = koios_post_request_raw( + url, + b"\x84\xa4\x00\x81", + {"Content-Type": "application/cbor"}, + ) + assert result == expected_response + + @responses.activate + def test_raw_post_request_raises_max_retries_exceeded(self, mocker): + """Test that MaxRetriesExceeded is raised after max retries.""" + mocker.patch("src.koios_api.library.SLEEP_TIME", 0) + mocker.patch("src.koios_api.library.MAX_RETRIES", 2) + + url = f"{API_BASE_URL}/submittx" + + for _ in range(2): + responses.add(responses.POST, url, status=500) + + with pytest.raises(MaxRetriesExceeded): + koios_post_request_raw( + url, + b"\x84\xa4\x00\x81", + {"Content-Type": "application/cbor"}, + ) + + +# ============================================================================= +# Tests for paginated_get +# ============================================================================= + + +@pytest.mark.unit +class TestPaginatedGet: + """Tests for the paginated_get function.""" + + @responses.activate + def test_paginated_get_single_page(self, mocker): + """Test paginated GET with a single page of results.""" + mocker.patch("src.koios_api.__config__.API_RESP_COUNT", 1000) + + url = f"{API_BASE_URL}/blocks" + # Return fewer than API_RESP_COUNT to indicate last page + expected_response = [{"hash": f"block{i}"} for i in range(10)] + + responses.add( + responses.GET, + url, + json=expected_response, + status=200, + ) + + result = paginated_get(url, {"limit": 10}) + assert len(result) == 10 + + @responses.activate + def test_paginated_get_multiple_pages(self, mocker): + """Test paginated GET with multiple pages of results.""" + mocker.patch("src.koios_api.__config__.API_RESP_COUNT", 10) + mocker.patch("src.koios_api.library.API_RESP_COUNT", 10) + + url = f"{API_BASE_URL}/blocks" + + # First page - full + page1 = [{"hash": f"block{i}"} for i in range(10)] + # Second page - partial (last page) + page2 = [{"hash": f"block{i}"} for i in range(10, 15)] + + responses.add(responses.GET, url, json=page1, status=200) + responses.add(responses.GET, url, json=page2, status=200) + + result = paginated_get(url, {}) + assert len(result) == 15 + + @responses.activate + def test_paginated_get_with_limit(self, mocker): + """Test paginated GET respects the limit parameter.""" + mocker.patch("src.koios_api.__config__.API_RESP_COUNT", 10) + mocker.patch("src.koios_api.library.API_RESP_COUNT", 10) + + url = f"{API_BASE_URL}/blocks" + + # Return full page + page1 = [{"hash": f"block{i}"} for i in range(10)] + + responses.add(responses.GET, url, json=page1, status=200) + + result = paginated_get(url, {}, limit=5) + assert len(result) == 5 + + @responses.activate + def test_paginated_get_with_offset(self, mocker): + """Test paginated GET with starting offset.""" + mocker.patch("src.koios_api.__config__.API_RESP_COUNT", 1000) + + url = f"{API_BASE_URL}/blocks" + expected_response = [{"hash": "block100"}] + + responses.add(responses.GET, url, json=expected_response, status=200) + + result = paginated_get(url, {}, offset=100) + assert len(result) == 1 + + +# ============================================================================= +# Tests for paginated_post +# ============================================================================= + + +@pytest.mark.unit +class TestPaginatedPost: + """Tests for the paginated_post function.""" + + @responses.activate + def test_paginated_post_single_page(self, mocker): + """Test paginated POST with a single page of results.""" + mocker.patch("src.koios_api.__config__.API_RESP_COUNT", 1000) + + url = f"{API_BASE_URL}/account_utxos" + expected_response = [{"tx_hash": f"tx{i}"} for i in range(10)] + + responses.add(responses.POST, url, json=expected_response, status=200) + + result = paginated_post(url, {}, {"_stake_addresses": ["stake1abc"]}) + assert len(result) == 10 + + @responses.activate + def test_paginated_post_with_limit(self, mocker): + """Test paginated POST respects the limit parameter.""" + mocker.patch("src.koios_api.__config__.API_RESP_COUNT", 10) + mocker.patch("src.koios_api.library.API_RESP_COUNT", 10) + + url = f"{API_BASE_URL}/account_utxos" + page1 = [{"tx_hash": f"tx{i}"} for i in range(10)] + + responses.add(responses.POST, url, json=page1, status=200) + + result = paginated_post(url, {}, {"_stake_addresses": ["stake1abc"]}, limit=5) + assert len(result) == 5 + + @responses.activate + def test_paginated_post_sets_default_limit(self, mocker): + """Test that paginated POST sets default limit in query parameters.""" + mocker.patch("src.koios_api.__config__.API_RESP_COUNT", 1000) + mocker.patch("src.koios_api.library.API_RESP_COUNT", 1000) + + url = f"{API_BASE_URL}/account_utxos" + expected_response = [{"tx_hash": "tx1"}] + + responses.add(responses.POST, url, json=expected_response, status=200) + + qs_params = {} + paginated_post(url, qs_params, {"_stake_addresses": ["stake1abc"]}) + assert qs_params.get("limit") == 1000 + + +# ============================================================================= +# Tests for MaxRetriesExceeded exception +# ============================================================================= + + +@pytest.mark.unit +class TestMaxRetriesExceeded: + """Tests for the MaxRetriesExceeded exception.""" + + def test_exception_message(self): + """Test that exception contains the correct message.""" + exc = MaxRetriesExceeded("Custom error message") + assert str(exc) == "Custom error message" + + def test_exception_inheritance(self): + """Test that MaxRetriesExceeded inherits from Exception.""" + exc = MaxRetriesExceeded("Test") + assert isinstance(exc, Exception) + + def test_exception_can_be_raised_and_caught(self): + """Test that exception can be raised and caught properly.""" + with pytest.raises(MaxRetriesExceeded): + raise MaxRetriesExceeded("Test error") + + +# ============================================================================= +# Edge Cases and Error Scenarios +# ============================================================================= + + +@pytest.mark.unit +class TestEdgeCases: + """Tests for edge cases and error scenarios.""" + + @responses.activate + def test_get_request_with_empty_response(self): + """Test GET request handling empty list response.""" + url = f"{API_BASE_URL}/tip" + + responses.add(responses.GET, url, json=[], status=200) + + result = koios_get_request(url, {}) + assert result == [] + + @responses.activate + def test_post_request_with_empty_response(self): + """Test POST request handling empty list response.""" + url = f"{API_BASE_URL}/account_info" + + responses.add(responses.POST, url, json=[], status=200) + + result = koios_post_request(url, {}, {"_stake_addresses": []}) + assert result == [] + + @responses.activate + def test_get_request_with_rate_limit_error(self, mocker): + """Test GET request handling rate limit (429) error.""" + mocker.patch("src.koios_api.library.SLEEP_TIME", 0) + mocker.patch("src.koios_api.library.MAX_RETRIES", 2) + + url = f"{API_BASE_URL}/tip" + + responses.add( + responses.GET, + url, + json={"message": "Rate limit exceeded"}, + status=429, + ) + responses.add(responses.GET, url, json=[{"epoch_no": 450}], status=200) + + result = koios_get_request(url, {}) + assert result == [{"epoch_no": 450}] + + @responses.activate + def test_get_request_with_malformed_json(self, mocker): + """Test GET request handling malformed JSON response.""" + mocker.patch("src.koios_api.library.SLEEP_TIME", 0) + mocker.patch("src.koios_api.library.MAX_RETRIES", 2) + + url = f"{API_BASE_URL}/tip" + + # First response is malformed JSON + responses.add( + responses.GET, + url, + body="not valid json{", + status=200, + ) + # Second response is valid + responses.add(responses.GET, url, json=[{"epoch_no": 450}], status=200) + + result = koios_get_request(url, {}) + assert result == [{"epoch_no": 450}] diff --git a/tox.ini b/tox.ini index f4aab2c..a7cc510 100644 --- a/tox.ini +++ b/tox.ini @@ -1,13 +1,34 @@ [tox] -envlist = py3,linting +envlist = py3,unit,integration,linting skipsdist = true [testenv] deps = -r requirements.txt skip_install = true -whitelist_externals = pytest +allowlist_externals = pytest commands = pytest -c pytest.ini +[testenv:unit] +description = Run unit tests only (fast, no network) +deps = -r requirements.txt +skip_install = true +allowlist_externals = pytest +commands = pytest -c pytest.ini -m unit --cov=src/koios_api --cov-report=term-missing + +[testenv:integration] +description = Run integration tests only (requires network) +deps = -r requirements.txt +skip_install = true +allowlist_externals = pytest +commands = pytest -c pytest.ini -m integration --cov=src/koios_api --cov-report=term-missing + +[testenv:coverage] +description = Run all tests with coverage report +deps = -r requirements.txt +skip_install = true +allowlist_externals = pytest +commands = pytest -c pytest.ini --cov=src/koios_api --cov-report=term-missing --cov-report=html --cov-report=xml + [testenv:linting] basepython = python3 deps = pre-commit