Add proof-measure client + Gitea PyPI publish; bump to 0.4.0
All checks were successful
Publish to PyPI Registry / publish (release) Successful in 1m14s
All checks were successful
Publish to PyPI Registry / publish (release) Successful in 1m14s
proof-measure is a separate, public, unauthenticated Dragonchain service. Adds: - UnauthenticatedClient: HMAC-free transport mirroring Client (session injection, allow_redirects=False, from_dict decoding). - ProofMeasureClient: get_security / report / health; default base URL https://proof-measure.dragonchain.com. Standalone (ProofMeasureClient()) and via DragonchainSDK.proof_measure. - Proof-measure dataclass models (decimals as strings, timestamps as int). - .gitea/workflows/publish.yml: build + twine upload to the Gitea PyPI registry on release (the SDK had no publish workflow before). Also fixes 3 pre-existing failing tests: _FakeSession.request didn't accept the allow_redirects kwarg the client now passes (added by the prior redirect change), so the suite was red at HEAD.
This commit is contained in:
@@ -71,11 +71,24 @@ from .models import (
|
||||
TransactionTypeCreateResponse,
|
||||
VerificationBlock,
|
||||
)
|
||||
from .models import (
|
||||
AnchorSecurity,
|
||||
HashPower,
|
||||
HealthResponse,
|
||||
RawMeasure,
|
||||
ReportAnchorInput,
|
||||
ReportRequest,
|
||||
SecurityResult,
|
||||
TransactionReport,
|
||||
)
|
||||
from .proof_measure import DEFAULT_BASE_URL as PROOF_MEASURE_DEFAULT_BASE_URL
|
||||
from .proof_measure import ProofMeasureClient
|
||||
from .system import SystemClient
|
||||
from .transaction import TransactionClient
|
||||
from .transaction_type import TransactionTypeClient
|
||||
from .unauthenticated_client import UnauthenticatedClient
|
||||
|
||||
__version__ = "0.2.0"
|
||||
__version__ = "0.4.0"
|
||||
|
||||
|
||||
class DragonchainSDK:
|
||||
@@ -111,6 +124,10 @@ class DragonchainSDK:
|
||||
self.contract = ContractClient(self._client)
|
||||
self.block = BlockClient(self._client)
|
||||
self.system = SystemClient(self._client)
|
||||
# proof-measure is a separate, unauthenticated public service; this
|
||||
# handle targets its default public endpoint. For a custom endpoint
|
||||
# construct a ``ProofMeasureClient`` directly.
|
||||
self.proof_measure = ProofMeasureClient()
|
||||
|
||||
def get_client(self) -> Client:
|
||||
"""Return the underlying HTTP client (advanced use)."""
|
||||
@@ -128,6 +145,9 @@ __all__ = [
|
||||
"SystemClient",
|
||||
"TransactionClient",
|
||||
"TransactionTypeClient",
|
||||
"ProofMeasureClient",
|
||||
"UnauthenticatedClient",
|
||||
"PROOF_MEASURE_DEFAULT_BASE_URL",
|
||||
# models
|
||||
"Block",
|
||||
"BlockHeader",
|
||||
@@ -155,4 +175,13 @@ __all__ = [
|
||||
"TransactionTypeCreateRequest",
|
||||
"TransactionTypeCreateResponse",
|
||||
"VerificationBlock",
|
||||
# proof-measure models
|
||||
"RawMeasure",
|
||||
"SecurityResult",
|
||||
"ReportAnchorInput",
|
||||
"ReportRequest",
|
||||
"AnchorSecurity",
|
||||
"HashPower",
|
||||
"TransactionReport",
|
||||
"HealthResponse",
|
||||
]
|
||||
|
||||
@@ -492,3 +492,169 @@ class ListResponse:
|
||||
@classmethod
|
||||
def from_dict(cls, d: Dict[str, Any]) -> "ListResponse":
|
||||
return cls(items=d.get("items") or [], total_count=d.get("total_count", 0))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Proof Measure (measured immutability / "securedBy")
|
||||
# --------------------------------------------------------------------------- #
|
||||
#
|
||||
# Decimal-valued fields are kept as strings (full precision); timestamps are int
|
||||
# unix seconds. JSON keys are camelCase, preserved verbatim from the service.
|
||||
|
||||
|
||||
@dataclass
|
||||
class RawMeasure:
|
||||
"""A network's native cumulative security measure: cumulative hashes for
|
||||
PoW, stake-seconds for PoS."""
|
||||
|
||||
value: str = "" # human-scaled mantissa, e.g. "484.44"
|
||||
unit: str = "" # e.g. "Zettahashes", "ETH·s"
|
||||
base: str = "" # unscaled base amount (hashes or stake-seconds)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: Dict[str, Any]) -> "RawMeasure":
|
||||
return cls(
|
||||
value=d.get("value", ""),
|
||||
unit=d.get("unit", ""),
|
||||
base=d.get("base", ""),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SecurityResult:
|
||||
"""Security a single network accrued for a window/anchor, as the raw native
|
||||
measure AND a USD valuation, plus a normalized 0..1 score."""
|
||||
|
||||
network: str = ""
|
||||
consensus: str = "" # "pow" | "pos"
|
||||
raw: RawMeasure = field(default_factory=RawMeasure)
|
||||
value_usd: str = ""
|
||||
value_usd_formatted: str = ""
|
||||
label: str = ""
|
||||
normalized_score: str = ""
|
||||
since: int = 0 # window/anchor start (unix seconds)
|
||||
as_of: int = 0 # latest sample time (unix seconds)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: Dict[str, Any]) -> "SecurityResult":
|
||||
return cls(
|
||||
network=d.get("network", ""),
|
||||
consensus=d.get("consensus", ""),
|
||||
raw=RawMeasure.from_dict(d.get("raw") or {}),
|
||||
value_usd=d.get("valueUsd", ""),
|
||||
value_usd_formatted=d.get("valueUsdFormatted", ""),
|
||||
label=d.get("label", ""),
|
||||
normalized_score=d.get("normalizedScore", ""),
|
||||
since=d.get("since", 0),
|
||||
as_of=d.get("asOf", 0),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReportAnchorInput:
|
||||
"""One anchor supplied in a report request. Provide either ``network``
|
||||
("BTC"/"ETH") or the public-chain numeric ``chain_id``; network wins."""
|
||||
|
||||
tx_hash: str
|
||||
timestamp: int # anchor time, unix seconds
|
||||
network: Optional[str] = None
|
||||
chain_id: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
d: Dict[str, Any] = {"txHash": self.tx_hash, "timestamp": self.timestamp}
|
||||
if self.network:
|
||||
d["network"] = self.network
|
||||
if self.chain_id:
|
||||
d["chainId"] = self.chain_id
|
||||
return d
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReportRequest:
|
||||
"""Body of ``ProofMeasureClient.report``: a transaction's interchain anchors."""
|
||||
|
||||
transaction_id: str
|
||||
prime_id: str
|
||||
block_id: str
|
||||
anchors: List[ReportAnchorInput] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"transactionId": self.transaction_id,
|
||||
"primeId": self.prime_id,
|
||||
"blockId": self.block_id,
|
||||
"anchors": [a.to_dict() for a in self.anchors],
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnchorSecurity:
|
||||
"""One interchain anchor with the security its public network has accumulated
|
||||
since the anchor was placed."""
|
||||
|
||||
network: str = ""
|
||||
anchor_timestamp: int = 0 # unix seconds
|
||||
anchor_tx_hash: str = ""
|
||||
security: SecurityResult = field(default_factory=SecurityResult)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: Dict[str, Any]) -> "AnchorSecurity":
|
||||
return cls(
|
||||
network=d.get("network", ""),
|
||||
anchor_timestamp=d.get("anchorTimestamp", 0),
|
||||
anchor_tx_hash=d.get("anchorTxHash", ""),
|
||||
security=SecurityResult.from_dict(d.get("security") or {}),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HashPower:
|
||||
"""Combined raw hash power across a report's PoW anchors."""
|
||||
|
||||
value: str = ""
|
||||
units: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: Dict[str, Any]) -> "HashPower":
|
||||
return cls(value=d.get("value", ""), units=d.get("units", ""))
|
||||
|
||||
|
||||
@dataclass
|
||||
class TransactionReport:
|
||||
"""Per-transaction "securedBy" report: every public-chain anchor covering the
|
||||
transaction's block with both raw and USD security, plus combined totals.
|
||||
``hash_power`` is None when there are no PoW anchors."""
|
||||
|
||||
transaction_id: str = ""
|
||||
prime_id: str = ""
|
||||
block_id: str = ""
|
||||
anchors: List[AnchorSecurity] = field(default_factory=list)
|
||||
total_value_usd: str = ""
|
||||
total_value_usd_formatted: str = ""
|
||||
hash_power: Optional[HashPower] = None
|
||||
total_normalized_score: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: Dict[str, Any]) -> "TransactionReport":
|
||||
hp = d.get("hashPower")
|
||||
return cls(
|
||||
transaction_id=d.get("transactionId", ""),
|
||||
prime_id=d.get("primeId", ""),
|
||||
block_id=d.get("blockId", ""),
|
||||
anchors=[AnchorSecurity.from_dict(a) for a in (d.get("anchors") or [])],
|
||||
total_value_usd=d.get("totalValueUsd", ""),
|
||||
total_value_usd_formatted=d.get("totalValueUsdFormatted", ""),
|
||||
hash_power=HashPower.from_dict(hp) if hp else None,
|
||||
total_normalized_score=d.get("totalNormalizedScore", ""),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HealthResponse:
|
||||
"""proof-measure liveness payload."""
|
||||
|
||||
status: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: Dict[str, Any]) -> "HealthResponse":
|
||||
return cls(status=d.get("status", ""))
|
||||
|
||||
64
prime_sdk/proof_measure.py
Normal file
64
prime_sdk/proof_measure.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Client for the Dragonchain proof-measure service.
|
||||
|
||||
proof-measure is the measured-immutability / "securedBy" metric for L1–L5
|
||||
verification chains. It is a separate, public, UNauthenticated service (no API
|
||||
keys), so this client needs only a base URL. It exposes a network's accumulated
|
||||
security as both a raw measure (cumulative hashes / stake-seconds) and a USD
|
||||
valuation, plus a per-transaction "securedBy" report over interchain anchors.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
from .client import DEFAULT_TIMEOUT
|
||||
from .models import (
|
||||
CONTENT_TYPE_JSON,
|
||||
HealthResponse,
|
||||
ReportRequest,
|
||||
SecurityResult,
|
||||
TransactionReport,
|
||||
)
|
||||
from .unauthenticated_client import UnauthenticatedClient
|
||||
|
||||
#: Public production proof-measure endpoint.
|
||||
DEFAULT_BASE_URL = "https://proof-measure.dragonchain.com"
|
||||
|
||||
|
||||
class ProofMeasureClient:
|
||||
"""Calls the proof-measure HTTP API.
|
||||
|
||||
With no arguments it targets the public production endpoint, so
|
||||
``ProofMeasureClient()`` works with zero configuration.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = DEFAULT_BASE_URL,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
session: "Optional[requests.Session]" = None,
|
||||
):
|
||||
self._client = UnauthenticatedClient(
|
||||
base_url or DEFAULT_BASE_URL, timeout=timeout, session=session
|
||||
)
|
||||
|
||||
def get_security(self, network: str, since: Optional[int] = None) -> SecurityResult:
|
||||
"""Return the security a public network (``network`` = "BTC" or "ETH")
|
||||
has accumulated since ``since`` (unix seconds), as a raw measure + USD
|
||||
valuation. Omit ``since`` to use the service's default window."""
|
||||
path = f"/api/v1/security/{network}"
|
||||
if since and since > 0:
|
||||
path += f"?since={since}"
|
||||
return self._client.get(path, SecurityResult)
|
||||
|
||||
def report(self, req: ReportRequest) -> TransactionReport:
|
||||
"""Compute the per-transaction "securedBy" report for the supplied
|
||||
interchain anchors: each anchor's raw + USD security since it was placed,
|
||||
plus combined totals."""
|
||||
return self._client.post(
|
||||
"/api/v1/report", CONTENT_TYPE_JSON, req, TransactionReport
|
||||
)
|
||||
|
||||
def health(self) -> HealthResponse:
|
||||
"""Report service liveness and DB reachability."""
|
||||
return self._client.get("/api/v1/health", HealthResponse)
|
||||
89
prime_sdk/unauthenticated_client.py
Normal file
89
prime_sdk/unauthenticated_client.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""HTTP client for public Dragonchain services that require no credentials.
|
||||
|
||||
Mirrors :class:`prime_sdk.client.Client`'s request plumbing — body marshaling,
|
||||
``allow_redirects=False``, status handling, and ``from_dict`` decoding — but
|
||||
sends no DC1-HMAC-SHA256 Authorization/Dragonchain/Timestamp headers. Used by the
|
||||
proof-measure client, which talks to a separate, public, unauthenticated service.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any, Optional, Type, TypeVar
|
||||
|
||||
import requests
|
||||
|
||||
from .client import CONTENT_TYPE_JSON, DEFAULT_TIMEOUT, _to_jsonable
|
||||
from .errors import DragonchainAPIError
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class UnauthenticatedClient:
|
||||
"""Unauthenticated HTTP client for a single public service base URL."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
session: Optional[requests.Session] = None,
|
||||
):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.timeout = timeout
|
||||
# A caller may inject a pre-configured Session (e.g. one with an
|
||||
# SSRF-guarded transport adapter) when base_url is attacker-influenced.
|
||||
self._session = session if session is not None else requests.Session()
|
||||
|
||||
def endpoint(self) -> str:
|
||||
return self.base_url
|
||||
|
||||
def _do_request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
content_type: str = "",
|
||||
body: Any = None,
|
||||
response_cls: Optional[Type[T]] = None,
|
||||
) -> Optional[T]:
|
||||
if isinstance(body, (bytes, bytearray)):
|
||||
body_bytes = bytes(body)
|
||||
elif body is not None:
|
||||
body_bytes = json.dumps(_to_jsonable(body)).encode("utf-8")
|
||||
content_type = CONTENT_TYPE_JSON
|
||||
else:
|
||||
body_bytes = b""
|
||||
|
||||
headers = {"Accept": CONTENT_TYPE_JSON}
|
||||
if content_type:
|
||||
headers["Content-Type"] = content_type
|
||||
|
||||
url = self.base_url + path
|
||||
# allow_redirects=False for the same reason as the authenticated client:
|
||||
# a JSON API never legitimately 3xx-redirects, and following one is an
|
||||
# SSRF vector.
|
||||
resp = self._session.request(
|
||||
method,
|
||||
url,
|
||||
data=body_bytes,
|
||||
headers=headers,
|
||||
timeout=self.timeout,
|
||||
allow_redirects=False,
|
||||
)
|
||||
|
||||
resp_text = resp.text
|
||||
if resp.status_code >= 400:
|
||||
raise DragonchainAPIError(resp.status_code, resp_text.strip())
|
||||
|
||||
if response_cls is not None and resp_text:
|
||||
return response_cls.from_dict(json.loads(resp_text)) # type: ignore[attr-defined]
|
||||
return None
|
||||
|
||||
def get(self, path: str, response_cls: Optional[Type[T]] = None) -> Optional[T]:
|
||||
return self._do_request("GET", path, response_cls=response_cls)
|
||||
|
||||
def post(
|
||||
self,
|
||||
path: str,
|
||||
content_type: str,
|
||||
body: Any,
|
||||
response_cls: Optional[Type[T]] = None,
|
||||
) -> Optional[T]:
|
||||
return self._do_request("POST", path, content_type, body, response_cls)
|
||||
Reference in New Issue
Block a user