Initial commit: Python SDK for Dragonchain (Prime)

Synchronous Python SDK modeled on prime-sdk-go. Provides DC1-HMAC-SHA256
auth, dataclass models, and resource clients for system, transaction,
transaction-type, smart-contract, and block endpoints, plus a YAML
credentials loader.
This commit is contained in:
2026-05-29 16:53:16 -04:00
commit 4a7d8b875a
15 changed files with 1686 additions and 0 deletions

223
tests/test_client.py Normal file
View File

@@ -0,0 +1,223 @@
"""Offline tests for the Dragonchain Python SDK.
No network access required. The HMAC test independently recomputes the expected
Authorization header to guard the byte-for-byte contract with the prime-node
authorizer.
"""
import base64
import hashlib
import hmac
import json
import pytest
from prime_sdk import (
DragonchainAPIError,
DragonchainSDK,
SmartContractCreateRequest,
Transaction,
TransactionCreateRequest,
)
from prime_sdk.client import Client
from prime_sdk.credentials import load_config_from_string
PUBLIC_ID = "test-public-id"
AUTH_KEY_ID = "test-key-id"
AUTH_KEY = "test-secret-key"
BASE_URL = "https://chains.dragonchain.com"
def _expected_auth_header(method, path, timestamp, content_type, body: bytes) -> str:
"""Recompute the expected header from scratch, independent of the SDK."""
b64_content = base64.b64encode(hashlib.sha256(body).digest()).decode()
message = "\n".join([method.upper(), path, PUBLIC_ID, timestamp, content_type, b64_content])
digest = hmac.new(AUTH_KEY.encode(), message.encode(), hashlib.sha256).digest()
return f"DC1-HMAC-SHA256 {AUTH_KEY_ID}:{base64.b64encode(digest).decode()}"
# --------------------------------------------------------------------------- #
# HMAC signing
# --------------------------------------------------------------------------- #
def test_hmac_message_format():
c = Client(PUBLIC_ID, AUTH_KEY_ID, AUTH_KEY, BASE_URL)
msg = c._hmac_message("post", "/api/v1/transaction", "1700000000", "application/json", b"hello")
lines = msg.split("\n")
assert len(lines) == 6
assert lines[0] == "POST" # method uppercased
assert lines[1] == "/api/v1/transaction"
assert lines[2] == PUBLIC_ID
assert lines[3] == "1700000000"
assert lines[4] == "application/json"
assert lines[5] == base64.b64encode(hashlib.sha256(b"hello").digest()).decode()
assert not msg.endswith("\n") # no trailing newline
def test_auth_header_matches_independent_computation():
c = Client(PUBLIC_ID, AUTH_KEY_ID, AUTH_KEY, BASE_URL)
body = b'{"txn_type":"x","payload":"y"}'
got = c._generate_auth_header("POST", "/api/v1/transaction", "1700000000", "application/json", body)
want = _expected_auth_header("POST", "/api/v1/transaction", "1700000000", "application/json", body)
assert got == want
def test_empty_body_signs_sha256_of_empty():
c = Client(PUBLIC_ID, AUTH_KEY_ID, AUTH_KEY, BASE_URL)
got = c._generate_auth_header("GET", "/api/v1/status", "1700000000", "", b"")
want = _expected_auth_header("GET", "/api/v1/status", "1700000000", "", b"")
assert got == want
def test_base_url_trailing_slash_stripped():
c = Client(PUBLIC_ID, AUTH_KEY_ID, AUTH_KEY, BASE_URL + "/")
assert c.endpoint() == BASE_URL
# --------------------------------------------------------------------------- #
# Request plumbing (with a fake session)
# --------------------------------------------------------------------------- #
class _FakeResponse:
def __init__(self, status_code, text):
self.status_code = status_code
self.text = text
class _FakeSession:
def __init__(self, response):
self.response = response
self.calls = []
def request(self, method, url, data=None, headers=None, timeout=None):
self.calls.append({"method": method, "url": url, "data": data, "headers": headers})
return self.response
def test_create_sends_correct_request_and_parses_response():
sdk = DragonchainSDK(PUBLIC_ID, AUTH_KEY_ID, AUTH_KEY, BASE_URL)
fake = _FakeSession(_FakeResponse(200, json.dumps({"transaction_id": "txn-123"})))
sdk.get_client()._session = fake
resp = sdk.transaction.create(
TransactionCreateRequest(txn_type="my-type", payload='{"a":1}', tag="t")
)
assert resp.transaction_id == "txn-123"
call = fake.calls[0]
assert call["method"] == "POST"
assert call["url"] == BASE_URL + "/api/v1/transaction"
# headers present and consistent
headers = call["headers"]
assert headers["Dragonchain"] == PUBLIC_ID
assert headers["Content-Type"] == "application/json"
assert headers["Authorization"].startswith(f"DC1-HMAC-SHA256 {AUTH_KEY_ID}:")
# the bytes sent are exactly the bytes that were signed
sent = call["data"]
expected_auth = _expected_auth_header(
"POST", "/api/v1/transaction", headers["Timestamp"], "application/json", sent
)
assert headers["Authorization"] == expected_auth
# omitempty: version dropped, tag kept
body = json.loads(sent)
assert body == {"txn_type": "my-type", "payload": '{"a":1}', "tag": "t"}
def test_error_status_raises():
sdk = DragonchainSDK(PUBLIC_ID, AUTH_KEY_ID, AUTH_KEY, BASE_URL)
sdk.get_client()._session = _FakeSession(_FakeResponse(404, " not found "))
with pytest.raises(DragonchainAPIError) as exc:
sdk.transaction.get("missing")
assert exc.value.status_code == 404
assert exc.value.body == "not found"
def test_get_sends_no_content_type_header():
sdk = DragonchainSDK(PUBLIC_ID, AUTH_KEY_ID, AUTH_KEY, BASE_URL)
fake = _FakeSession(_FakeResponse(200, json.dumps({"id": "chain", "level": 1})))
sdk.get_client()._session = fake
sdk.system.status()
headers = fake.calls[0]["headers"]
assert "Content-Type" not in headers
assert fake.calls[0]["data"] == b""
# --------------------------------------------------------------------------- #
# Model (de)serialization
# --------------------------------------------------------------------------- #
def test_transaction_from_dict_nested():
raw = {
"version": "1",
"header": {"txn_id": "abc", "block_id": "42", "txn_type": "t"},
"proof": {"full": "ff", "stripped": "ss"},
"payload": "{}",
}
txn = Transaction.from_dict(raw)
assert txn.header.txn_id == "abc"
assert txn.header.block_id == "42"
assert txn.proof.full == "ff"
assert txn.payload == "{}"
def test_contract_create_request_omitempty():
req = SmartContractCreateRequest(
environment="python3.8",
transaction_type="my-type",
execution_order="parallel",
)
d = req.to_dict()
assert d == {
"environment": "python3.8",
"transactionType": "my-type",
"executionOrder": "parallel",
}
# remote False is dropped; envvars/secrets absent
assert "remote" not in d
assert "environmentVariables" not in d
assert "secret" not in d
def test_contract_create_request_with_optionals():
req = SmartContractCreateRequest(
environment="python3.8",
transaction_type="my-type",
execution_order="serial",
environment_variables={"K": "V"},
secrets={"S": "v"},
remote=True,
)
d = req.to_dict()
assert d["environmentVariables"] == {"K": "V"}
assert d["secret"] == {"S": "v"}
assert d["remote"] is True
def test_credentials_round_trip():
yaml_text = """
default: pub-1
chains:
- name: alpha
publicId: pub-1
authKeyId: kid-1
authKey: secret-1
endpoint: https://chains.dragonchain.com
- name: beta
publicId: pub-2
authKeyId: kid-2
authKey: secret-2
endpoint: https://dev-chains.dragonchain.com
"""
cfg = load_config_from_string(yaml_text)
assert cfg.default == "pub-1"
assert cfg.list_chains() == ["alpha", "beta"]
default = cfg.get_default_chain()
assert default is not None and default.auth_key_id == "kid-1"
assert cfg.get_chain_by_public_id("pub-2").endpoint.endswith("dev-chains.dragonchain.com")
with pytest.raises(ValueError):
cfg.delete_chain("pub-1") # default
cfg.delete_chain("pub-2")
assert cfg.list_chains() == ["alpha"]