diff --git a/.github/scripts/codegen-check.sh b/.github/scripts/codegen-check.sh new file mode 100644 index 0000000..3f27ae3 --- /dev/null +++ b/.github/scripts/codegen-check.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# +# CI artifact — not part of the SDK. +# +# Renders the .trix/client-lib codegen plugin against the shared transfer +# fixture and verifies the result the way a consumer would: the rendered module +# is imported in a fresh venv with the dependencies its generated +# requirements.txt pins installed — no editable install of the SDK source tree. +# +# Requires `tx3c` and `python` on PATH. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +gen="$(mktemp -d)" +trap 'rm -rf "$gen"' EXIT + +tx3c codegen \ + --tii "$repo_root/sdk/tests/fixtures/transfer.tii" \ + --template "$repo_root/.trix/client-lib" \ + --output "$gen" + +for f in __init__.py requirements.txt; do + test -f "$gen/$f" || { echo "missing generated file: $f"; exit 1; } +done + +for sym in \ + 'TARGET_TII_VERSION' \ + 'PROFILES' \ + 'TRANSFER_TIR' \ + 'class TransferParams' \ + 'class Client'; do + grep -qF "$sym" "$gen/__init__.py" || { echo "generated __init__.py missing: $sym"; exit 1; } +done + +# Import the rendered module in a fresh venv with the dependencies its generated +# requirements.txt pins, exactly as an end user would consume it. +python -m venv "$gen/venv" +"$gen/venv/bin/pip" install --quiet --upgrade pip +"$gen/venv/bin/pip" install --quiet -r "$gen/requirements.txt" +"$gen/venv/bin/python" -c " +import importlib.util, sys +spec = importlib.util.spec_from_file_location('tx3_generated_protocol', '$gen/__init__.py') +module = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = module +spec.loader.exec_module(module) +assert module.TARGET_TII_VERSION == 'v1beta0', module.TARGET_TII_VERSION +assert set(module.PROFILES) == {'local', 'preprod'}, module.PROFILES +assert module.TRANSFER_TIR.version == 'v1beta0' +assert module.TransferParams(quantity=1).quantity == 1 +print('generated module imported OK') +" + +echo "codegen check passed" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bcb1475..83e2165 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,27 @@ jobs: working-directory: ./sdk run: pytest tests -m "not e2e" + codegen: + name: codegen + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: pip + cache-dependency-path: sdk/pyproject.toml + + - name: Setup tx3 toolchain + uses: tx3-lang/actions/setup@v1 + + - name: Render and verify codegen plugin + run: bash .github/scripts/codegen-check.sh + e2e: name: e2e runs-on: ubuntu-latest diff --git a/.trix/client-lib/__init__.py.hbs b/.trix/client-lib/__init__.py.hbs index 1097127..8ea89a6 100644 --- a/.trix/client-lib/__init__.py.hbs +++ b/.trix/client-lib/__init__.py.hbs @@ -1,55 +1,88 @@ -# This file is auto-generated. +# This file is auto-generated by trix codegen. +# Target TII version: {{tii.tii.version}} +from __future__ import annotations + +import json from dataclasses import dataclass -from typing import Dict, Any -from tx3_sdk.trp import Client as TRPClient, TxEnvelope, SubmitResponse, SubmitParams +from typing import Any -DEFAULT_TRP_ENDPOINT = "{{trpEndpoint}}" +from tx3_sdk.core.bytes import TirEnvelope +from tx3_sdk.trp.client import TrpClient +from tx3_sdk.trp.spec import ResolveParams, SubmitParams, SubmitResponse, TxEnvelope -DEFAULT_HEADERS = { -{{#each headers}} - "{{@key}}": "{{this}}", -{{/each}} -} +PROTOCOL_NAME = "{{tii.protocol.name}}" +PROTOCOL_VERSION = "{{tii.protocol.version}}" +TARGET_TII_VERSION = "{{tii.tii.version}}" -DEFAULT_ENV_ARGS = { -{{#each envArgs}} - "{{@key}}": "{{this}}", -{{/each}} -} +{{#if tii.environment}} +# JSON Schema of the protocol environment, embedded verbatim from the TII. +ENVIRONMENT_SCHEMA: dict[str, Any] = json.loads( + """{{{json tii.environment}}}""" +) -{{#each transactions}} +{{/if}} +# Profiles embedded from the TII, keyed by name. Each value carries that +# profile's `environment` values and `parties` addresses. +PROFILES: dict[str, dict[str, Any]] = json.loads( + """{{{json tii.profiles}}}""" +) + +{{#each tii.transactions}} @dataclass -class {{pascalCase params_name}}: -{{#each parameters}} - {{snakeCase name}}: {{typeFor type_name "python"}} # {{type_name}} +class {{pascalCase @key}}Params: + """Arguments for the {{@key}} transaction.""" + +{{#each params.properties}} + {{snakeCase @key}}: {{schemaTypeFor this "python"}} {{/each}} -{{constantCase constant_name}} = { - "content": "{{ir_bytes}}", - "encoding": "hex", - "version": "{{ir_version}}", -} + +{{constantCase @key}}_TIR = TirEnvelope( + content="{{tir.content}}", + encoding="{{tir.encoding}}", + version="{{tir.version}}", +) {{/each}} class Client: - def __init__(self, options: Dict[str, Any]): - self._client = TRPClient(options) -{{#each transactions}} - - async def {{snakeCase function_name}}(self, args: {{pascalCase params_name}}) -> TxEnvelope: - return await self._client.resolve({ - "tir": {{constantCase constant_name}}, - "args": vars(args), - }) + """Thin protocol facade over the TRP client.""" + + def __init__( + self, + endpoint: str, + headers: dict[str, str] | None = None, + profile: str | None = None, + ) -> None: + self._trp = TrpClient(endpoint=endpoint, headers=headers) + self._environment: dict[str, Any] | None = None + self._parties: dict[str, str] = {} + if profile is not None: + if profile not in PROFILES: + raise ValueError(f"unknown profile {profile!r}") + selected = PROFILES[profile] + self._environment = selected.get("environment") or None + self._parties = selected.get("parties") or {} + + async def _resolve(self, tir: TirEnvelope, args: dict[str, Any]) -> TxEnvelope: + merged: dict[str, Any] = {**self._parties, **args} + return await self._trp.resolve( + ResolveParams(tir=tir, args=merged, env=self._environment) + ) +{{#each tii.transactions}} + + async def {{snakeCase @key}}(self, args: {{pascalCase @key}}Params) -> TxEnvelope: + """Resolves the {{@key}} transaction.""" + return await self._resolve( + {{constantCase @key}}_TIR, + { +{{#each params.properties}} + "{{@key}}": args.{{snakeCase @key}}, +{{/each}} + }, + ) {{/each}} async def submit(self, params: SubmitParams) -> SubmitResponse: - return await self._client.submit(params) - -# Create a default client instance -protocol = Client({ - "endpoint": DEFAULT_TRP_ENDPOINT, - "headers": DEFAULT_HEADERS, - "env_args": DEFAULT_ENV_ARGS, -}) + """Submits a signed transaction to the network.""" + return await self._trp.submit(params)