diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49f296f..558f58d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,66 @@ on: branches: [master, main] jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Ruff lint + run: ruff check src/ tests/ examples/ + + - name: Ruff format check + run: ruff format --check src/ tests/ examples/ + + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Mypy + run: mypy src/ + + security: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Bandit security scan + run: bandit -r src/ -c pyproject.toml + + - name: Dependency audit + run: pip-audit + test: runs-on: ubuntu-latest strategy: @@ -25,15 +85,21 @@ jobs: python -m pip install --upgrade pip pip install -e ".[dev]" - - name: Lint - run: ruff check src/ tests/ + - name: Run unit tests with coverage + run: | + pytest tests/test_types.py tests/test_daemon_client.py \ + -v --cov=mina_sdk --cov-report=term-missing --cov-report=xml - - name: Run unit tests - run: pytest tests/test_types.py tests/test_daemon_client.py -v + - name: Upload coverage + if: matrix.python-version == '3.12' + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage.xml build: runs-on: ubuntu-latest - needs: test + needs: [lint, typecheck, security, test] steps: - uses: actions/checkout@v4 diff --git a/pyproject.toml b/pyproject.toml index a22c8fb..289992f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,8 +35,12 @@ archive = ["asyncpg>=0.29"] dev = [ "pytest>=8.0", "pytest-asyncio>=0.24", + "pytest-cov>=6.0", "respx>=0.20", "ruff>=0.8", + "mypy>=1.13", + "bandit>=1.8", + "pip-audit>=2.7", ] [project.urls] @@ -50,9 +54,48 @@ packages = ["src/mina_sdk"] [tool.ruff] target-version = "py38" line-length = 100 +src = ["src", "tests"] [tool.ruff.lint] -select = ["E", "F", "I", "W"] +select = [ + "E", # pycodestyle errors + "F", # pyflakes + "I", # isort + "W", # pycodestyle warnings + "N", # pep8-naming + "UP", # pyupgrade + "B", # flake8-bugbear + "A", # flake8-builtins + "S", # flake8-bandit (security) + "T20", # flake8-print + "SIM", # flake8-simplify + "RUF", # ruff-specific rules +] +ignore = [ + "S101", # allow assert in tests + "T201", # allow print in examples + "A001", # ConnectionError shadows builtin (fixed in quality PR) + "A004", # ConnectionError import shadows builtin (fixed in quality PR) + "N818", # CurrencyUnderflow naming (fixed in quality PR) + "S105", # false positive on "TOKEN" in query variable names + "S311", # random.randint not crypto-safe (Currency.random is not for crypto) +] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["S101", "S106"] +"examples/*" = ["T201"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" + +[tool.mypy] +python_version = "3.10" +warn_return_any = false +warn_unused_configs = true +disallow_untyped_defs = true +check_untyped_defs = true +ignore_missing_imports = true [tool.pytest.ini_options] testpaths = ["tests"] @@ -60,3 +103,23 @@ asyncio_mode = "auto" markers = [ "integration: requires a running Mina daemon (set MINA_GRAPHQL_URI)", ] + +[tool.coverage.run] +source = ["mina_sdk"] +branch = true + +[tool.coverage.report] +show_missing = true +fail_under = 70 +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "if __name__", +] + +[tool.bandit] +exclude_dirs = ["tests"] +skips = [ + "B105", # false positive: "TOKEN" in GraphQL query variable names + "B311", # random.randint is fine for Currency.random (not crypto) +] diff --git a/src/mina_sdk/__init__.py b/src/mina_sdk/__init__.py index 8b7c932..1af3fab 100644 --- a/src/mina_sdk/__init__.py +++ b/src/mina_sdk/__init__.py @@ -32,23 +32,19 @@ ) __all__ = [ - # Client - "MinaDaemonClient", - # Exceptions - "GraphQLError", - "DaemonConnectionError", - "CurrencyUnderflow", - # Currency - "Currency", - "CurrencyFormat", - # Data types "AccountBalance", "AccountData", "BlockInfo", + "Currency", + "CurrencyFormat", + "CurrencyUnderflow", + "DaemonConnectionError", "DaemonStatus", + "GraphQLError", + "MinaDaemonClient", "PeerInfo", - "SendPaymentResult", "SendDelegationResult", + "SendPaymentResult", ] __version__ = "0.1.0" diff --git a/src/mina_sdk/daemon/client.py b/src/mina_sdk/daemon/client.py index 5eed121..644a44a 100644 --- a/src/mina_sdk/daemon/client.py +++ b/src/mina_sdk/daemon/client.py @@ -50,7 +50,7 @@ class DaemonConnectionError(Exception): # Keep the old name as an alias for backwards compatibility -ConnectionError = DaemonConnectionError # noqa: A001 +ConnectionError = DaemonConnectionError class MinaDaemonClient: @@ -209,9 +209,7 @@ def get_network_id(self) -> str: data = self._request(queries.NETWORK_ID, query_name="get_network_id") return data["networkID"] - def get_account( - self, public_key: str, token_id: str | None = None - ) -> AccountData: + def get_account(self, public_key: str, token_id: str | None = None) -> AccountData: """Get account data for a public key. Args: @@ -228,9 +226,7 @@ def get_account( ) else: variables = {"publicKey": public_key} - data = self._request( - queries.GET_ACCOUNT, variables=variables, query_name="get_account" - ) + data = self._request(queries.GET_ACCOUNT, variables=variables, query_name="get_account") acc = data.get("account") if acc is None: raise ValueError(f"account not found: {public_key}") diff --git a/src/mina_sdk/types.py b/src/mina_sdk/types.py index 69f1a62..fbd4c28 100644 --- a/src/mina_sdk/types.py +++ b/src/mina_sdk/types.py @@ -172,9 +172,7 @@ def __sub__(self, other: Currency) -> Currency: if isinstance(other, Currency): result = self._nanomina - other._nanomina if result < 0: - raise CurrencyUnderflow( - f"subtraction would result in negative: {self} - {other}" - ) + raise CurrencyUnderflow(f"subtraction would result in negative: {self} - {other}") return Currency.from_nanomina(result) return NotImplemented diff --git a/tests/test_daemon_client.py b/tests/test_daemon_client.py index ca07562..51a47e5 100644 --- a/tests/test_daemon_client.py +++ b/tests/test_daemon_client.py @@ -41,19 +41,21 @@ def test_sync_status_bootstrap(client): @respx.mock def test_daemon_status(client): - respx.post(GRAPHQL_URL).mock(return_value=_gql_response({ - "daemonStatus": { - "syncStatus": "SYNCED", - "blockchainLength": 100, - "highestBlockLengthReceived": 100, - "uptimeSecs": 3600, - "stateHash": "3NKtest...", - "commitId": "abc123", - "peers": [ - {"peerId": "peer1", "host": "1.2.3.4", "libp2pPort": 8302} - ], - } - })) + respx.post(GRAPHQL_URL).mock( + return_value=_gql_response( + { + "daemonStatus": { + "syncStatus": "SYNCED", + "blockchainLength": 100, + "highestBlockLengthReceived": 100, + "uptimeSecs": 3600, + "stateHash": "3NKtest...", + "commitId": "abc123", + "peers": [{"peerId": "peer1", "host": "1.2.3.4", "libp2pPort": 8302}], + } + } + ) + ) status = client.get_daemon_status() assert status.sync_status == "SYNCED" assert status.blockchain_length == 100 @@ -72,19 +74,23 @@ def test_network_id(client): @respx.mock def test_get_account(client): - respx.post(GRAPHQL_URL).mock(return_value=_gql_response({ - "account": { - "publicKey": "B62qtest...", - "nonce": "5", - "delegate": "B62qdelegate...", - "tokenId": "1", - "balance": { - "total": "1500000000000", - "liquid": "1000000000000", - "locked": "500000000000", - }, - } - })) + respx.post(GRAPHQL_URL).mock( + return_value=_gql_response( + { + "account": { + "publicKey": "B62qtest...", + "nonce": "5", + "delegate": "B62qdelegate...", + "tokenId": "1", + "balance": { + "total": "1500000000000", + "liquid": "1000000000000", + "locked": "500000000000", + }, + } + } + ) + ) account = client.get_account("B62qtest...") assert account.public_key == "B62qtest..." assert account.nonce == 5 @@ -103,22 +109,26 @@ def test_get_account_not_found(client): @respx.mock def test_best_chain(client): - respx.post(GRAPHQL_URL).mock(return_value=_gql_response({ - "bestChain": [ + respx.post(GRAPHQL_URL).mock( + return_value=_gql_response( { - "stateHash": "3NKhash1", - "commandTransactionCount": 3, - "creatorAccount": {"publicKey": "B62qcreator..."}, - "protocolState": { - "consensusState": { - "blockHeight": "50", - "slotSinceGenesis": "1000", - "slot": "500", + "bestChain": [ + { + "stateHash": "3NKhash1", + "commandTransactionCount": 3, + "creatorAccount": {"publicKey": "B62qcreator..."}, + "protocolState": { + "consensusState": { + "blockHeight": "50", + "slotSinceGenesis": "1000", + "slot": "500", + } + }, } - }, + ] } - ] - })) + ) + ) blocks = client.get_best_chain(max_length=1) assert len(blocks) == 1 assert blocks[0].state_hash == "3NKhash1" @@ -135,15 +145,19 @@ def test_best_chain_empty(client): @respx.mock def test_send_payment(client): - respx.post(GRAPHQL_URL).mock(return_value=_gql_response({ - "sendPayment": { - "payment": { - "id": "txn-id-123", - "hash": "CkpHash...", - "nonce": "6", + respx.post(GRAPHQL_URL).mock( + return_value=_gql_response( + { + "sendPayment": { + "payment": { + "id": "txn-id-123", + "hash": "CkpHash...", + "nonce": "6", + } + } } - } - })) + ) + ) result = client.send_payment( sender="B62qsender...", receiver="B62qreceiver...", @@ -157,11 +171,11 @@ def test_send_payment(client): @respx.mock def test_send_payment_string_amounts(client): - respx.post(GRAPHQL_URL).mock(return_value=_gql_response({ - "sendPayment": { - "payment": {"id": "x", "hash": "y", "nonce": "1"} - } - })) + respx.post(GRAPHQL_URL).mock( + return_value=_gql_response( + {"sendPayment": {"payment": {"id": "x", "hash": "y", "nonce": "1"}}} + ) + ) result = client.send_payment( sender="B62qsender...", receiver="B62qreceiver...", @@ -173,15 +187,19 @@ def test_send_payment_string_amounts(client): @respx.mock def test_send_delegation(client): - respx.post(GRAPHQL_URL).mock(return_value=_gql_response({ - "sendDelegation": { - "delegation": { - "id": "del-id-456", - "hash": "CkpDel...", - "nonce": "7", + respx.post(GRAPHQL_URL).mock( + return_value=_gql_response( + { + "sendDelegation": { + "delegation": { + "id": "del-id-456", + "hash": "CkpDel...", + "nonce": "7", + } + } } - } - })) + ) + ) result = client.send_delegation( sender="B62qsender...", delegate_to="B62qdelegate...", @@ -202,9 +220,7 @@ def test_graphql_error(client): @respx.mock def test_connection_error_after_retries(): respx.post(GRAPHQL_URL).mock(side_effect=httpx.ConnectError("refused")) - client = MinaDaemonClient( - graphql_uri=GRAPHQL_URL, retries=2, retry_delay=0.0, timeout=1.0 - ) + client = MinaDaemonClient(graphql_uri=GRAPHQL_URL, retries=2, retry_delay=0.0, timeout=1.0) with pytest.raises(ConnectionError, match="after 2 attempts"): client.get_sync_status() client.close() @@ -219,12 +235,16 @@ def test_context_manager(): @respx.mock def test_get_peers(client): - respx.post(GRAPHQL_URL).mock(return_value=_gql_response({ - "getPeers": [ - {"peerId": "p1", "host": "10.0.0.1", "libp2pPort": 8302}, - {"peerId": "p2", "host": "10.0.0.2", "libp2pPort": 8302}, - ] - })) + respx.post(GRAPHQL_URL).mock( + return_value=_gql_response( + { + "getPeers": [ + {"peerId": "p1", "host": "10.0.0.1", "libp2pPort": 8302}, + {"peerId": "p2", "host": "10.0.0.2", "libp2pPort": 8302}, + ] + } + ) + ) peers = client.get_peers() assert len(peers) == 2 assert peers[0].peer_id == "p1" @@ -233,20 +253,24 @@ def test_get_peers(client): @respx.mock def test_pooled_user_commands(client): - respx.post(GRAPHQL_URL).mock(return_value=_gql_response({ - "pooledUserCommands": [ + respx.post(GRAPHQL_URL).mock( + return_value=_gql_response( { - "id": "cmd1", - "hash": "CkpHash1", - "kind": "PAYMENT", - "nonce": "1", - "amount": "1000000000", - "fee": "10000000", - "from": "B62qsender...", - "to": "B62qreceiver...", + "pooledUserCommands": [ + { + "id": "cmd1", + "hash": "CkpHash1", + "kind": "PAYMENT", + "nonce": "1", + "amount": "1000000000", + "fee": "10000000", + "from": "B62qsender...", + "to": "B62qreceiver...", + } + ] } - ] - })) + ) + ) cmds = client.get_pooled_user_commands("B62qsender...") assert len(cmds) == 1 assert cmds[0]["kind"] == "PAYMENT" @@ -278,9 +302,7 @@ def test_empty_graphql_uri(): @respx.mock def test_json_decode_error(): respx.post(GRAPHQL_URL).mock(return_value=httpx.Response(200, text="not json")) - client = MinaDaemonClient( - graphql_uri=GRAPHQL_URL, retries=1, retry_delay=0.0, timeout=5.0 - ) + client = MinaDaemonClient(graphql_uri=GRAPHQL_URL, retries=1, retry_delay=0.0, timeout=5.0) with pytest.raises(ConnectionError, match="Invalid JSON"): client.get_sync_status() client.close() @@ -289,9 +311,7 @@ def test_json_decode_error(): @respx.mock def test_http_500_retried(): respx.post(GRAPHQL_URL).mock(return_value=httpx.Response(500, text="Internal Server Error")) - client = MinaDaemonClient( - graphql_uri=GRAPHQL_URL, retries=2, retry_delay=0.0, timeout=5.0 - ) + client = MinaDaemonClient(graphql_uri=GRAPHQL_URL, retries=2, retry_delay=0.0, timeout=5.0) with pytest.raises(ConnectionError, match="after 2 attempts"): client.get_sync_status() client.close() @@ -301,10 +321,9 @@ def test_http_500_retried(): def test_daemon_connection_error_alias(): """DaemonConnectionError and ConnectionError are the same class.""" from mina_sdk.daemon.client import DaemonConnectionError + respx.post(GRAPHQL_URL).mock(side_effect=httpx.ConnectError("refused")) - client = MinaDaemonClient( - graphql_uri=GRAPHQL_URL, retries=1, retry_delay=0.0, timeout=1.0 - ) + client = MinaDaemonClient(graphql_uri=GRAPHQL_URL, retries=1, retry_delay=0.0, timeout=1.0) with pytest.raises(DaemonConnectionError): client.get_sync_status() client.close()