diff --git a/.github/workflows/sdk-readme.yml b/.github/workflows/sdk-readme.yml new file mode 100644 index 0000000000..9d86066049 --- /dev/null +++ b/.github/workflows/sdk-readme.yml @@ -0,0 +1,89 @@ +name: SDK README Guard + +on: + push: + branches: [main] + paths: + - 'sdk/**/README.md' + - 'sdk/README.md' + - 'sdk/packages/rust/iii/examples/readme_hello.rs' + - 'sdk/packages/rust/iii/src/**' + - 'sdk/packages/python/iii/src/**' + - 'sdk/fixtures/readme-guard/**' + - 'scripts/readme-guard/**' + - '.github/workflows/sdk-readme.yml' + pull_request: + branches: [main] + paths: + - 'sdk/**/README.md' + - 'sdk/README.md' + - 'sdk/packages/rust/iii/examples/readme_hello.rs' + - 'sdk/packages/rust/iii/src/**' + - 'sdk/packages/python/iii/src/**' + - 'sdk/fixtures/readme-guard/**' + - 'scripts/readme-guard/**' + - '.github/workflows/sdk-readme.yml' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +# This workflow runs in warn-only mode so it can ship alongside the first round +# of README fixes without blocking merges. After the follow-up PR flips +# `continue-on-error` to `false`, any new README drift will fail CI. +jobs: + rust-readme: + name: Rust — cargo check --example readme_hello + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: Compile the canonical Rust Hello World + run: cargo check -p iii-sdk --example readme_hello + + python-readme: + name: Python — attribute-probe against real `iii.III` + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install guard deps + editable SDK + run: | + python -m pip install --upgrade pip + python -m pip install markdown-it-py + python -m pip install -e sdk/packages/python/iii + + - name: Verify golden-good fixture passes + run: python scripts/readme-guard/check_python_readme.py sdk/fixtures/readme-guard/golden-good-python.md + + - name: Verify golden-bad fixture fails (meta-test) + # The guard *must* exit non-zero on this fixture. If it exits 0, the + # guard is silently broken and would let real bugs ship. + run: | + set +e + python scripts/readme-guard/check_python_readme.py sdk/fixtures/readme-guard/golden-bad-python.md + rc=$? + set -e + if [ "$rc" = "0" ]; then + echo "::error::Guard passed the golden-bad fixture — it is silently broken." + exit 1 + fi + echo "Guard correctly rejected golden-bad (exit $rc)." + + - name: Run guard against real READMEs + run: | + python scripts/readme-guard/check_python_readme.py \ + sdk/packages/python/iii/README.md \ + sdk/packages/node/iii/README.md \ + sdk/README.md diff --git a/scripts/readme-guard/check_python_readme.py b/scripts/readme-guard/check_python_readme.py new file mode 100644 index 0000000000..685da3744d --- /dev/null +++ b/scripts/readme-guard/check_python_readme.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Verify Python README code blocks use only real `III` attributes. + +Catches the class of bug where a README teaches `iii.connect()` while the +actual class exposes only `connect_async()`. Compile-only checks (py_compile, +mypy without type stubs) pass such code because `iii.connect()` is valid +Python syntax — it AttributeErrors only at runtime. + +Usage: + python check_python_readme.py README.md [README.md ...] + +Exit code 0 = all snippets reference real attributes, 1 = at least one +snippet calls a method that does not exist on `iii.III`. + +The script uses `markdown-it-py` to parse code fences correctly (no regex) +and `ast` + `inspect` to resolve attribute references against the real +class. +""" + +from __future__ import annotations + +import ast +import inspect +import sys +from pathlib import Path + +try: + from markdown_it import MarkdownIt +except ImportError: # pragma: no cover - bootstrap guidance + sys.stderr.write( + "check_python_readme: markdown-it-py is required. " + "Install with `pip install markdown-it-py`.\n" + ) + sys.exit(2) + + +def extract_python_fences(md_text: str) -> list[tuple[int, str]]: + """Return (line_number, source) for each ```python fenced block.""" + md = MarkdownIt("commonmark") + tokens = md.parse(md_text) + fences: list[tuple[int, str]] = [] + for tok in tokens: + if tok.type != "fence": + continue + info = (tok.info or "").strip().lower() + # Accept `python`, `py`, or info strings that start with `python` + # (e.g., `python3`, `python,ignore`). + if info == "python" or info == "py" or info.startswith("python"): + line = (tok.map[0] + 1) if tok.map else 0 + fences.append((line, tok.content)) + return fences + + +def iii_class_attrs() -> set[str]: + """Return the set of public attribute names available on `iii.III`.""" + from iii.iii import III # noqa: E402 - runtime import + + return {name for name, _ in inspect.getmembers(III) if not name.startswith("_")} + + +def attr_calls_on_iii_var(source: str) -> list[tuple[int, str]]: + """Find method calls of the form `iii.(...)` in a snippet. + + Returns a list of (lineno, attr_name). Heuristic: we treat any bare + identifier named ``iii`` as an `III` instance. The Hello World pattern + `iii = register_worker(...)` makes this reliable. + """ + try: + tree = ast.parse(source) + except SyntaxError: + # Syntax errors are a separate class of problem; `py_compile` already + # catches them. Skip attribute checking when parse fails. + return [] + + calls: list[tuple[int, str]] = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + fn = node.func + if not isinstance(fn, ast.Attribute): + continue + if not isinstance(fn.value, ast.Name): + continue + if fn.value.id != "iii": + continue + calls.append((node.lineno, fn.attr)) + return calls + + +def check_file(path: Path, allowed_attrs: set[str]) -> list[str]: + errors: list[str] = [] + md_text = path.read_text() + for block_line, source in extract_python_fences(md_text): + for call_lineno, attr in attr_calls_on_iii_var(source): + if attr in allowed_attrs: + continue + # Allow the dynamic `attr` sentinel names we know about: + # e.g., handler closures aren't on `iii`. + errors.append( + f"{path}:{block_line + call_lineno - 1}: " + f"iii.{attr}() is called but `iii.III` has no public " + f"attribute named `{attr}`. " + f"Did the README outlive a refactor?" + ) + return errors + + +def main() -> int: + if len(sys.argv) < 2: + sys.stderr.write(f"usage: {sys.argv[0]} README.md [README.md ...]\n") + return 2 + + try: + allowed = iii_class_attrs() + except Exception as exc: # pragma: no cover - import failure path + sys.stderr.write( + f"check_python_readme: failed to import `iii.III` to probe its " + f"attributes: {exc}\n" + "Make sure the iii-sdk package is installed (editable mode is " + "fine) before running this check.\n" + ) + return 2 + + all_errors: list[str] = [] + for arg in sys.argv[1:]: + all_errors.extend(check_file(Path(arg), allowed)) + + for msg in all_errors: + sys.stderr.write(msg + "\n") + + return 0 if not all_errors else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/sdk/README.md b/sdk/README.md index 6f83a0a61e..7360ba2ead 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -15,6 +15,16 @@ These are iii official SDKs for Node, Python, and Rust. See the [engine README]( | [`iii-sdk`](https://pypi.org/project/iii-sdk/) | Python | `pip install iii-sdk` | [README](./packages/python/iii/README.md) | | [`iii-sdk`](https://crates.io/crates/iii-sdk) | Rust | Add to `Cargo.toml` | [README](./packages/rust/iii/README.md) | +## Prerequisites + +All three SDKs connect to a running iii engine over WebSocket. Before running +any snippet below: + +1. Install the engine: `curl -fsSL https://install.iii.dev/iii/main/install.sh | sh` +2. Start the engine: `iii --config config.yaml` (default URL: `ws://localhost:49134`) + +See the [quickstart](https://iii.dev/docs/quickstart) to scaffold a full project. + ## Hello World ### Node.js @@ -47,12 +57,12 @@ iii = register_worker("ws://localhost:49134") def greet(data): return {"message": f"Hello, {data['name']}!"} -iii.register_function({"id": "greet"}, greet) +iii.register_function("greet", greet) iii.register_trigger({ "type": "http", "function_id": "greet", - "config": {"api_path": "/greet", "http_method": "POST"} + "config": {"api_path": "/greet", "http_method": "POST"}, }) result = iii.trigger({"function_id": "greet", "payload": {"name": "world"}}) @@ -60,45 +70,54 @@ result = iii.trigger({"function_id": "greet", "payload": {"name": "world"}}) ### Rust -```rust -use iii_sdk::{register_worker, InitOptions, TriggerRequest, RegisterFunctionMessage, RegisterTriggerInput}; -use serde_json::json; +```rust,no_run +use iii_sdk::builtin_triggers::{HttpMethod, HttpTriggerConfig}; +use iii_sdk::{IIITrigger, InitOptions, RegisterFunction, TriggerRequest, register_worker}; +use serde_json::{Value, json}; #[tokio::main] async fn main() -> Result<(), Box> { - let iii = register_worker("ws://127.0.0.1:49134", InitOptions::default())?; + let iii = register_worker("ws://localhost:49134", InitOptions::default()); - iii.register_function(RegisterFunctionMessage::with_id("greet".into()), |input| async move { + iii.register_function(RegisterFunction::new_async("greet", |input: Value| async move { let name = input.get("name").and_then(|v| v.as_str()).unwrap_or("world"); - Ok(json!({ "message": format!("Hello, {name}!") })) - }); + Ok::(json!({ "message": format!("Hello, {name}!") })) + })); - iii.register_trigger(RegisterTriggerInput { trigger_type: "http".into(), function_id: "greet".into(), config: json!({ - "api_path": "/greet", - "http_method": "POST" - }) })?; + iii.register_trigger( + IIITrigger::Http(HttpTriggerConfig::new("/greet").method(HttpMethod::Post)) + .for_function("greet"), + )?; - let result: serde_json::Value = iii + let result: Value = iii .trigger(TriggerRequest::new("greet", json!({ "name": "world" }))) .await?; + iii.shutdown(); Ok(()) } ``` -## API - -| Operation | Node.js | Python | Rust | Description | -| ------------------------ | ---------------------------------------------------- | ------------------------------------------- | -------------------------------------------- | ------------------------------------------------------ | -| Initialize | `registerWorker(url)` | `register_worker(url, options?)` | `register_worker(url, options)` | Create an SDK instance and auto-connect | -| Register function | `iii.registerFunction(id, handler, options?)` | `iii.register_function(id, handler)` | `iii.register_function(id, \|input\| ...)` | Register a function that can be invoked by name | -| Register trigger | `iii.registerTrigger({ type, function_id, config })` | `iii.register_trigger({"type": ..., "function_id": ..., "config": ...})` | `iii.register_trigger(type, fn_id, config)?` | Bind a trigger (HTTP, cron, queue, etc.) to a function | -| Invoke (await) | `await iii.trigger({ function_id, payload })` | `await iii.trigger({"function_id": id, "payload": data})` | `iii.trigger(TriggerRequest::new(id, data)).await?` | Invoke a function and wait for the result | -| Invoke (fire-and-forget) | `iii.trigger({ function_id, payload, action: TriggerAction.Void() })` | Same | Same | Invoke without waiting | +The Rust snippet is kept in sync with `sdk/packages/rust/iii/examples/readme_hello.rs` +and verified by CI. -`registerWorker()` / `register_worker()` creates an SDK instance and auto-connects to the engine. It handles WebSocket communication, automatic reconnection, and OpenTelemetry instrumentation. All three SDKs expose the same API surface — register functions and triggers, then invoke them. +## API -> `call`, `callVoid`, `triggerVoid` (and Python/Rust equivalents) have been removed. Use `trigger()` for all invocations. For fire-and-forget, use `trigger({ function_id, payload, action: TriggerAction.Void() })`. +| Operation | Node.js | Python | Rust | Description | +| ------------------------ | ------------------------------------------------------------- | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------ | +| Initialize | `registerWorker(url)` | `register_worker(url, options?)` | `register_worker(url, options)` | Create an SDK instance and auto-connect | +| Register function | `iii.registerFunction(id, handler, options?)` | `iii.register_function(id, handler)` | `iii.register_function(RegisterFunction::new_async(id, handler))` | Register a function that can be invoked by name | +| Register trigger | `iii.registerTrigger({ type, function_id, config })` | `iii.register_trigger({"type": ..., "function_id": ..., "config": ...})` | `iii.register_trigger(IIITrigger::Http(...).for_function(id))?` | Bind a trigger (HTTP, cron, queue, etc.) to a function | +| Invoke (await) | `await iii.trigger({ function_id, payload })` | `await iii.trigger({"function_id": id, "payload": data})` | `iii.trigger(TriggerRequest::new(id, payload)).await?` | Invoke a function and wait for the result | +| Invoke (fire-and-forget) | `iii.trigger({ function_id, payload, action: TriggerAction.Void() })` | `iii.trigger({"function_id": id, "payload": data, "action": TriggerAction.Void()})` | `iii.trigger(TriggerRequest::new(id, payload).with_action(TriggerAction::Void)).await?` | Invoke without waiting | + +`registerWorker()` / `register_worker()` creates an SDK instance and auto-connects +to the engine. It handles WebSocket communication, automatic reconnection, and +OpenTelemetry instrumentation. Rust's `register_worker` returns `III` directly +(not `Result`); Node/Python mirror this. All three SDKs expose the same concept +surface — register functions and triggers, then invoke them. + +> `call`, `callVoid`, `triggerVoid` (and Python/Rust equivalents) have been removed. Use `trigger()` for all invocations. For fire-and-forget, use `trigger({ function_id, payload, action: TriggerAction.Void() })` (Node/Python) or `TriggerRequest::new(id, payload).with_action(TriggerAction::Void)` (Rust). For language-specific details (modules, streams, OpenTelemetry), see the per-SDK READMEs linked in the table above. diff --git a/sdk/fixtures/readme-guard/golden-bad-python.md b/sdk/fixtures/readme-guard/golden-bad-python.md new file mode 100644 index 0000000000..009bf577ed --- /dev/null +++ b/sdk/fixtures/readme-guard/golden-bad-python.md @@ -0,0 +1,17 @@ +# Golden bad (Python) + +This fixture MUST make the guard exit 1 — it contains the exact bug the +guard was built to catch: `iii.connect()` is valid Python syntax but the +`iii.III` class does not expose a public `connect` method. + +```python +from iii import register_worker + +iii = register_worker("ws://localhost:49134") + +def greet(data): + return {"message": f"Hello, {data['name']}!"} + +iii.register_function("greet", greet) +iii.connect() # <-- intentional bug: no such method +``` diff --git a/sdk/fixtures/readme-guard/golden-good-python.md b/sdk/fixtures/readme-guard/golden-good-python.md new file mode 100644 index 0000000000..da0f7118bb --- /dev/null +++ b/sdk/fixtures/readme-guard/golden-good-python.md @@ -0,0 +1,23 @@ +# Golden good (Python) + +This fixture exists so CI can prove the guard is *working* — not silently +letting everything pass. The guard must exit 0 on this file. + +```python +from iii import register_worker + +iii = register_worker("ws://localhost:49134") + +def greet(data): + return {"message": f"Hello, {data['name']}!"} + +iii.register_function("greet", greet) + +iii.register_trigger({ + "type": "http", + "function_id": "greet", + "config": {"api_path": "/greet", "http_method": "POST"}, +}) + +result = iii.trigger({"function_id": "greet", "payload": {"name": "world"}}) +``` diff --git a/sdk/packages/node/iii/CHANGELOG.md b/sdk/packages/node/iii/CHANGELOG.md new file mode 100644 index 0000000000..f48b885d4f --- /dev/null +++ b/sdk/packages/node/iii/CHANGELOG.md @@ -0,0 +1,28 @@ +# Changelog + +All notable changes to `iii-sdk` (Node.js) are documented here. The format is +based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this +project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Changed +- `NotConnected` / max-retries log message now names the URL and links to + `https://iii.dev/docs/install` so devs can diagnose connection failures without + leaving the terminal. + +### Documentation +- README now opens with a `Prerequisites` block covering engine install and + engine start — the two steps that were previously tribal knowledge. + +## [0.11.0] — 2025-12 + +### Removed +- `call`, `callVoid`, and `triggerVoid` have been removed. Use `trigger()` for + all invocations. For fire-and-forget: + ```javascript + iii.trigger({ function_id, payload, action: TriggerAction.Void() }) + ``` + +[Unreleased]: https://github.com/iii-hq/iii/tree/main/sdk/packages/node/iii +[0.11.0]: https://github.com/iii-hq/iii/releases/tag/iii/v0.11.0 diff --git a/sdk/packages/node/iii/README.md b/sdk/packages/node/iii/README.md index daadc39114..2520bac5b7 100644 --- a/sdk/packages/node/iii/README.md +++ b/sdk/packages/node/iii/README.md @@ -5,6 +5,16 @@ Node.js / TypeScript SDK for the [iii engine](https://github.com/iii-hq/iii). [![npm](https://img.shields.io/npm/v/iii-sdk)](https://www.npmjs.com/package/iii-sdk) [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](../../../LICENSE) +## Prerequisites + +The SDK connects to a running iii engine over WebSocket. Before running the snippets below: + +1. Install the engine: `curl -fsSL https://install.iii.dev/iii/main/install.sh | sh` +2. Start the engine in a separate terminal: `iii --config config.yaml` + (default WebSocket URL: `ws://localhost:49134`) + +See the [iii quickstart](https://iii.dev/docs/quickstart) for scaffolding a full project. + ## Install ```bash diff --git a/sdk/packages/node/iii/package.json b/sdk/packages/node/iii/package.json index ab26ecf215..7dc358e09d 100644 --- a/sdk/packages/node/iii/package.json +++ b/sdk/packages/node/iii/package.json @@ -14,6 +14,12 @@ "iii", "sdk" ], + "files": [ + "dist", + "README.md", + "CHANGELOG.md", + "LICENSE" + ], "scripts": { "build": "tsdown", "docs:json": "typedoc", diff --git a/sdk/packages/node/iii/src/iii.ts b/sdk/packages/node/iii/src/iii.ts index ccfd569c13..37fc5b3904 100644 --- a/sdk/packages/node/iii/src/iii.ts +++ b/sdk/packages/node/iii/src/iii.ts @@ -718,7 +718,12 @@ class Sdk implements ISdk { if (maxRetries !== -1 && this.reconnectAttempt >= maxRetries) { this.setConnectionState('failed') - this.logError(`Max reconnection retries (${maxRetries}) reached, giving up`) + this.logError( + `iii is not connected: engine unreachable at ${this.address} after ${maxRetries} retries. ` + + `Verify the engine is running (\`iii --config config.yaml\`) and that the WebSocket URL ` + + `passed to registerWorker matches (default: ws://localhost:49134). ` + + `See https://iii.dev/docs/install`, + ) return } diff --git a/sdk/packages/python/iii/CHANGELOG.md b/sdk/packages/python/iii/CHANGELOG.md new file mode 100644 index 0000000000..bc5e573c05 --- /dev/null +++ b/sdk/packages/python/iii/CHANGELOG.md @@ -0,0 +1,33 @@ +# Changelog + +All notable changes to `iii-sdk` (Python) are documented here. The format is +based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this +project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Changed +- `ConnectionError` raised by `register_worker` / `_wait_until_connected` now + includes the target URL, the command to start the engine, and a link to + `https://iii.dev/docs/install`. + +### Documentation +- README now opens with a `Prerequisites` block covering engine install and + engine start. +- Hello World switched to the string form `register_function("greet", handler)` + and removed the incorrect `iii.connect()` call — `register_worker` already + auto-connects and blocks until ready. +- Dict-form `register_function({"id": "greet"}, handler)` is still supported + but documented under "Advanced" instead of the teaching path. + +## [0.11.0] — 2025-12 + +### Removed +- Removed legacy `call`, `callVoid`, `triggerVoid` methods. Use `trigger()` + for all invocations: + ```python + iii.trigger({"function_id": id, "payload": data, "action": TriggerAction.Void()}) + ``` + +[Unreleased]: https://github.com/iii-hq/iii/tree/main/sdk/packages/python/iii +[0.11.0]: https://github.com/iii-hq/iii/releases/tag/iii/v0.11.0 diff --git a/sdk/packages/python/iii/README.md b/sdk/packages/python/iii/README.md index f0010a18dc..8af94ba450 100644 --- a/sdk/packages/python/iii/README.md +++ b/sdk/packages/python/iii/README.md @@ -6,6 +6,16 @@ Python SDK for the [iii engine](https://github.com/iii-hq/iii). [![Python](https://img.shields.io/pypi/pyversions/iii-sdk)](https://pypi.org/project/iii-sdk/) [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](../../../LICENSE) +## Prerequisites + +The SDK connects to a running iii engine over WebSocket. Before running the snippets below: + +1. Install the engine: `curl -fsSL https://install.iii.dev/iii/main/install.sh | sh` +2. Start the engine in a separate terminal: `iii --config config.yaml` + (default WebSocket URL: `ws://localhost:49134`) + +See the [iii quickstart](https://iii.dev/docs/quickstart) for scaffolding a full project. + ## Install ```bash @@ -22,7 +32,7 @@ iii = register_worker("ws://localhost:49134") def greet(data): return {"message": f"Hello, {data['name']}!"} -iii.register_function({"id": "greet"}, greet) +iii.register_function("greet", greet) iii.register_trigger({ "type": "http", @@ -30,32 +40,31 @@ iii.register_trigger({ "config": {"api_path": "/greet", "http_method": "POST"}, }) -iii.connect() - result = iii.trigger({"function_id": "greet", "payload": {"name": "world"}}) print(result) # {"message": "Hello, world!"} ``` +`register_worker()` auto-connects to the engine and blocks until the connection is +established. There is no separate `connect()` step. + ## API | Operation | Signature | Description | | ------------------------ | ------------------------------------------------- | ------------------------------------------------------ | | Initialize | `register_worker(url, options?)` | Create an SDK instance and auto-connect | -| Register function | `iii.register_function({"id": id}, handler)` | Register a function that can be invoked by name | +| Register function | `iii.register_function(id, handler)` | Register a function that can be invoked by name | | Register trigger | `iii.register_trigger({"type": ..., "function_id": ..., "config": ...})` | Bind a trigger (HTTP, cron, queue, etc.) to a function | | Invoke (await result) | `iii.trigger({"function_id": id, "payload": data})` | Invoke a function and wait for the result | | Invoke (fire-and-forget) | `iii.trigger({"function_id": id, ..., "action": TriggerAction.Void()})` | Fire-and-forget | | Shutdown | `iii.shutdown()` | Disconnect and stop background thread | -`register_worker()` creates the SDK instance and auto-connects to the engine. - ### Registering Functions ```python def create_order(data): return {"status_code": 201, "body": {"id": "123", "item": data["body"]["item"]}} -iii.register_function({"id": "orders.create"}, create_order) +iii.register_function("orders.create", create_order) ``` ### Registering Triggers @@ -74,6 +83,17 @@ iii.register_trigger({ result = iii.trigger({"function_id": "orders.create", "payload": {"body": {"item": "widget"}}}) ``` +### Advanced: dict-form registration + +`register_function` also accepts a `RegisterFunctionInput` or a dict with `id` +for callers that need to pass extra registration fields inline: + +```python +iii.register_function({"id": "orders.create", "description": "Create a new order"}, create_order) +``` + +Prefer the string form above for hello-world-style examples. + ## Modules | Import | What it provides | diff --git a/sdk/packages/python/iii/pyproject.toml b/sdk/packages/python/iii/pyproject.toml index 7e27e10f8f..eecdb49e8e 100644 --- a/sdk/packages/python/iii/pyproject.toml +++ b/sdk/packages/python/iii/pyproject.toml @@ -29,6 +29,7 @@ dependencies = [ [project.urls] Homepage = "https://github.com/iii-hq/sdk" Repository = "https://github.com/iii-hq/sdk" +Changelog = "https://github.com/iii-hq/iii/blob/main/sdk/packages/python/iii/CHANGELOG.md" [project.optional-dependencies] dev = [ @@ -45,6 +46,17 @@ dev = [ [tool.hatch.build.targets.wheel] packages = ["src/iii"] +[tool.hatch.build.targets.wheel.force-include] +"CHANGELOG.md" = "iii/CHANGELOG.md" + +[tool.hatch.build.targets.sdist] +include = [ + "src/iii", + "README.md", + "CHANGELOG.md", + "pyproject.toml", +] + [tool.ruff] line-length = 120 target-version = "py310" diff --git a/sdk/packages/python/iii/src/iii/iii.py b/sdk/packages/python/iii/src/iii/iii.py index 5c5d3906eb..c473292622 100644 --- a/sdk/packages/python/iii/src/iii/iii.py +++ b/sdk/packages/python/iii/src/iii/iii.py @@ -153,11 +153,19 @@ def _wait_until_connected(self) -> None: if self._connection_state == "connected": return if self._connection_state == "failed": - raise ConnectionError(f"Connection to {self._address} failed") + raise ConnectionError( + f"iii is not connected: engine unreachable at {self._address}. " + "Verify the engine is running (`iii --config config.yaml`) and that " + "the WebSocket URL passed to register_worker matches " + "(default: ws://localhost:49134). " + "See https://iii.dev/docs/install" + ) self._connected_event.wait(timeout=30) if cast(IIIConnectionState, self._connection_state) == "failed": raise ConnectionError( - f"Connection to {self._address} failed after max retries" + f"iii is not connected: engine unreachable at {self._address} after max retries. " + "Verify the engine is running (`iii --config config.yaml`) and reachable. " + "See https://iii.dev/docs/install" ) def shutdown(self) -> None: diff --git a/sdk/packages/python/iii/tests/test_init_api.py b/sdk/packages/python/iii/tests/test_init_api.py index d9db99b8a7..305c07ae9f 100644 --- a/sdk/packages/python/iii/tests/test_init_api.py +++ b/sdk/packages/python/iii/tests/test_init_api.py @@ -27,6 +27,26 @@ def test_register_worker_is_sync() -> None: assert not inspect.iscoroutinefunction(register_worker) +def test_not_connected_error_is_actionable(monkeypatch) -> None: + """Failed connection should raise with problem + cause + fix text.""" + import pytest + + from iii.iii import III + + iii = III.__new__(III) + iii._address = "ws://localhost:49134" + iii._connection_state = "failed" + # _wait_until_connected is the first wall a dev hits; assert content. + with pytest.raises(ConnectionError) as excinfo: + iii._wait_until_connected() + msg = str(excinfo.value) + assert "iii is not connected" in msg + assert "engine unreachable" in msg + assert "ws://localhost:49134" in msg + assert "iii --config" in msg + assert "https://iii.dev/docs/install" in msg + + def test_connect_consumes_otel_from_init_options(monkeypatch) -> None: import iii.telemetry as telemetry diff --git a/sdk/packages/python/iii/uv.lock b/sdk/packages/python/iii/uv.lock index 50459ba389..f9af9209df 100644 --- a/sdk/packages/python/iii/uv.lock +++ b/sdk/packages/python/iii/uv.lock @@ -500,7 +500,7 @@ wheels = [ [[package]] name = "iii-sdk" -version = "0.11.0.dev9" +version = "0.11.2" source = { editable = "." } dependencies = [ { name = "opentelemetry-api" }, diff --git a/sdk/packages/rust/iii/CHANGELOG.md b/sdk/packages/rust/iii/CHANGELOG.md new file mode 100644 index 0000000000..990472a66d --- /dev/null +++ b/sdk/packages/rust/iii/CHANGELOG.md @@ -0,0 +1,41 @@ +# Changelog + +All notable changes to `iii-sdk` (Rust) are documented here. The format is +based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this +project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- `TriggerRequest::new(function_id, payload)` constructor for the common case. +- `TriggerRequest::with_action(action)` and `TriggerRequest::with_timeout_ms(ms)` + builder helpers for overriding defaults. +- `examples/readme_hello.rs` — the canonical source-of-truth for the Rust SDK + README Hello World. CI verifies it compiles via `cargo check --example readme_hello`. + +### Changed +- `IIIError::NotConnected` message now names the URL, the command to start the + engine, and a link to `https://iii.dev/docs/install` — the first real error a + new dev hits is now actionable instead of opaque. + +### Documentation +- README now opens with a `Prerequisites` block covering engine install and + engine start. +- Hello World uses `RegisterFunction::new_async`, the typed `IIITrigger::Http` + trigger builder, and the new `TriggerRequest::new` — all of which compile + against the current public API. +- Dropped the stale `iii-sdk = "0.3"` install line in favor of `"0.11"`. +- The struct-literal `RegisterTriggerInput { ... }` form is documented as an + "Advanced" escape hatch; the typed builder is the recommended path. + +## [0.11.0] — 2025-12 + +### Removed +- Removed legacy `call`, `call_void`, `trigger_void` methods. Use `trigger()` + for all invocations: + ```rust + iii.trigger(TriggerRequest::new(id, payload).with_action(TriggerAction::Void)).await? + ``` + +[Unreleased]: https://github.com/iii-hq/iii/tree/main/sdk/packages/rust/iii +[0.11.0]: https://github.com/iii-hq/iii/releases/tag/iii/v0.11.0 diff --git a/sdk/packages/rust/iii/README.md b/sdk/packages/rust/iii/README.md index 71729c8a48..9fb892c1d4 100644 --- a/sdk/packages/rust/iii/README.md +++ b/sdk/packages/rust/iii/README.md @@ -6,170 +6,192 @@ Rust SDK for the [iii engine](https://github.com/iii-hq/iii). [![docs.rs](https://img.shields.io/docsrs/iii-sdk)](https://docs.rs/iii-sdk) [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](../../../LICENSE) +## Prerequisites + +The SDK connects to a running iii engine over WebSocket. Before running the snippets below: + +1. Install the engine: `curl -fsSL https://install.iii.dev/iii/main/install.sh | sh` +2. Start the engine in a separate terminal: `iii --config config.yaml` + (default WebSocket URL: `ws://localhost:49134`) + +See the [iii quickstart](https://iii.dev/docs/quickstart) for scaffolding a full project. + ## Install Add to your `Cargo.toml`: ```toml [dependencies] -iii-sdk = "0.3" +iii-sdk = "0.11" serde_json = "1" tokio = { version = "1", features = ["full"] } ``` ## Hello World -```rust -use iii_sdk::{register_worker, InitOptions, TriggerRequest}; -use serde_json::{json, Value}; +```rust,no_run +use iii_sdk::builtin_triggers::{HttpMethod, HttpTriggerConfig}; +use iii_sdk::{IIITrigger, InitOptions, RegisterFunction, TriggerRequest, register_worker}; +use serde_json::{Value, json}; #[tokio::main] async fn main() -> Result<(), Box> { let iii = register_worker("ws://localhost:49134", InitOptions::default()); - iii.register_function("greet", |input: Value| async move { + iii.register_function(RegisterFunction::new_async("greet", |input: Value| async move { let name = input.get("name").and_then(|v| v.as_str()).unwrap_or("world"); - Ok(json!({ "message": format!("Hello, {name}!") })) - }); + Ok::(json!({ "message": format!("Hello, {name}!") })) + })); - iii.register_trigger("http", "greet", json!({ - "api_path": "/greet", - "http_method": "POST" - }))?; + iii.register_trigger( + IIITrigger::Http(HttpTriggerConfig::new("/greet").method(HttpMethod::Post)) + .for_function("greet"), + )?; let result: Value = iii - .trigger(TriggerRequest { - function_id: "greet".to_string(), - payload: json!({ "name": "world" }), - action: None, - timeout_ms: None, - }) + .trigger(TriggerRequest::new("greet", json!({ "name": "world" }))) .await?; println!("result: {result}"); + iii.shutdown(); Ok(()) } ``` +This snippet is kept in sync with [`examples/readme_hello.rs`](./examples/readme_hello.rs) +and verified by CI via `cargo check --example readme_hello`. + +`register_worker()` returns `III` directly (not `Result`). It spawns a background +task that handles WebSocket communication, automatic reconnection, and +OpenTelemetry instrumentation. + ## API | Operation | Signature | Description | | ------------------------ | ----------------------------------------------------------------- | ------------------------------------------------------ | | Initialize | `register_worker(address, options)` | Create an SDK instance and auto-connect | -| Register function | `iii.register_function(id, \|input: Value\| ...)` | Register a function that can be invoked by name | -| Register trigger | `iii.register_trigger(type, fn_id, config)?` | Bind a trigger (HTTP, cron, queue, etc.) to a function | -| Invoke (await) | `iii.trigger(TriggerRequest { ... }).await?` | Invoke a function and wait for the result | -| Invoke (fire-and-forget) | `iii.trigger(TriggerRequest { action: Some(TriggerAction::Void), ... }).await?` | Fire-and-forget invocation | -| Invoke (enqueue) | `iii.trigger(TriggerRequest { action: Some(TriggerAction::Enqueue { queue }), ... }).await?` | Route invocation through a named queue | - -`register_worker()` spawns a background task that handles WebSocket communication, automatic reconnection, and OpenTelemetry instrumentation. +| Register function | `iii.register_function(RegisterFunction::new_async(id, handler))` | Register a function that can be invoked by name | +| Register trigger | `iii.register_trigger(IIITrigger::Http(...).for_function(id))?` | Bind a trigger (HTTP, cron, queue, etc.) to a function | +| Invoke (await) | `iii.trigger(TriggerRequest::new(id, payload)).await?` | Invoke a function and wait for the result | +| Invoke (fire-and-forget) | `iii.trigger(TriggerRequest::new(id, payload).with_action(TriggerAction::Void)).await?` | Fire-and-forget invocation | +| Invoke (enqueue) | `iii.trigger(TriggerRequest::new(id, payload).with_action(TriggerAction::Enqueue { queue })).await?` | Route through a named queue | ### Registering Functions -```rust -use serde_json::{json, Value}; +Use `RegisterFunction::new_async` for async handlers, `RegisterFunction::new` for sync: + +```rust,ignore +use iii_sdk::RegisterFunction; +use serde_json::{Value, json}; -iii.register_function("orders.create", |input: Value| async move { +iii.register_function(RegisterFunction::new_async("orders.create", |input: Value| async move { let item = input["body"]["item"].as_str().unwrap_or(""); - Ok(json!({ "status_code": 201, "body": { "id": "123", "item": item } })) -}); + Ok::(json!({ + "status_code": 201, + "body": { "id": "123", "item": item } + })) +})); ``` ### Registering Triggers -```rust -iii.register_trigger("http", "orders.create", json!({ - "api_path": "/orders", - "http_method": "POST" -}))?; +**Recommended — typed builder** (self-documenting, compile-time checked): + +```rust,ignore +use iii_sdk::{IIITrigger, builtin_triggers::{HttpMethod, HttpTriggerConfig}}; + +iii.register_trigger( + IIITrigger::Http(HttpTriggerConfig::new("/orders").method(HttpMethod::Post)) + .for_function("orders.create"), +)?; +``` + +**Advanced — raw `RegisterTriggerInput`** (for custom trigger types or dynamic +configs): + +```rust,ignore +use iii_sdk::RegisterTriggerInput; +use serde_json::json; + +iii.register_trigger(RegisterTriggerInput { + trigger_type: "http".into(), + function_id: "orders.create".into(), + config: json!({ "api_path": "/orders", "http_method": "POST" }), + metadata: None, +})?; ``` ### Invoking Functions -```rust -use iii_sdk::{TriggerRequest, TriggerAction}; +```rust,ignore +use iii_sdk::{TriggerAction, TriggerRequest}; use serde_json::json; -// Synchronous -- waits for the result -let result = iii.trigger(TriggerRequest { - function_id: "orders.create".to_string(), - payload: json!({ "body": { "item": "widget" } }), - action: None, - timeout_ms: None, -}).await?; +// Synchronous — waits for the result +let result = iii + .trigger(TriggerRequest::new("orders.create", json!({ "body": { "item": "widget" } }))) + .await?; // Fire-and-forget -iii.trigger(TriggerRequest { - function_id: "analytics.track".to_string(), - payload: json!({ "event": "page_view" }), - action: Some(TriggerAction::Void), - timeout_ms: None, -}).await?; +iii.trigger( + TriggerRequest::new("analytics.track", json!({ "event": "page_view" })) + .with_action(TriggerAction::Void), +) +.await?; // Async via named queue -iii.trigger(TriggerRequest { - function_id: "orders.process".to_string(), - payload: json!({ "order_id": "456" }), - action: Some(TriggerAction::Enqueue { queue: "payments".to_string() }), - timeout_ms: None, -}).await?; +iii.trigger( + TriggerRequest::new("orders.process", json!({ "order_id": "456" })) + .with_action(TriggerAction::Enqueue { queue: "payments".into() }), +) +.await?; ``` ### Stream Operations -```rust -use iii_sdk::{register_worker, InitOptions, TriggerRequest, UpdateBuilder, UpdateOp}; +```rust,ignore +use iii_sdk::{TriggerRequest, UpdateBuilder}; use serde_json::json; -#[tokio::main] -async fn main() -> Result<(), Box> { - let iii = register_worker("ws://localhost:49134", InitOptions::default()); - - // Set a stream item - iii.trigger(TriggerRequest { - function_id: "stream::set".into(), - payload: json!({ - "stream_name": "users", - "group_id": "active", - "item_id": "user-1", - "data": { "status": "online" }, - }), - action: None, - timeout_ms: None, - }).await?; - - // Atomic update with UpdateBuilder - let ops = UpdateBuilder::new() - .increment("total", 100) - .set("status", json!("processing")) - .build(); - - iii.trigger(TriggerRequest { - function_id: "stream::update".into(), - payload: json!({ - "stream_name": "orders", - "group_id": "user-123", - "item_id": "order-456", - "ops": ops, - }), - action: None, - timeout_ms: None, - }).await?; - - Ok(()) -} +iii.trigger(TriggerRequest::new( + "stream::set", + json!({ + "stream_name": "users", + "group_id": "active", + "item_id": "user-1", + "data": { "status": "online" }, + }), +)) +.await?; + +let ops = UpdateBuilder::new() + .increment("total", 100) + .set("status", json!("processing")) + .build(); + +iii.trigger(TriggerRequest::new( + "stream::update", + json!({ + "stream_name": "orders", + "group_id": "user-123", + "item_id": "order-456", + "ops": ops, + }), +)) +.await?; ``` ### Logger -```rust +```rust,ignore use iii_sdk::Logger; let logger = Logger::new(Some("my-function".to_string())); logger.info("Processing started", None); ``` -The `Logger` struct emits OTel `LogRecord`s, falling back to the `tracing` crate when OTel is not initialized. +The `Logger` struct emits OTel `LogRecord`s, falling back to the `tracing` crate +when OTel is not initialized. ## Modules diff --git a/sdk/packages/rust/iii/examples/readme_hello.rs b/sdk/packages/rust/iii/examples/readme_hello.rs new file mode 100644 index 0000000000..46ddc945d6 --- /dev/null +++ b/sdk/packages/rust/iii/examples/readme_hello.rs @@ -0,0 +1,42 @@ +//! Canonical source-of-truth for the Rust SDK README "Hello World". +//! +//! The README embeds this file's contents; CI verifies it compiles with +//! `cargo check --example readme_hello`. Do not duplicate the code into the +//! README by hand — update this file and re-run the docs generator. + +use iii_sdk::builtin_triggers::{HttpMethod, HttpTriggerConfig}; +use iii_sdk::{ + IIITrigger, InitOptions, RegisterFunction, TriggerRequest, register_worker, +}; +use serde_json::{Value, json}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // register_worker returns `III` directly (not `Result`); it spawns a + // background runtime and auto-connects to the engine over WebSocket. + let iii = register_worker("ws://localhost:49134", InitOptions::default()); + + iii.register_function(RegisterFunction::new_async("greet", |input: Value| async move { + let name = input + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("world"); + Ok::(json!({ "message": format!("Hello, {name}!") })) + })); + + // Recommended: the typed IIITrigger builder. + iii.register_trigger( + IIITrigger::Http(HttpTriggerConfig::new("/greet").method(HttpMethod::Post)) + .for_function("greet"), + )?; + + // Invoke the function. `TriggerRequest::new` covers the 80% case; + // `.with_action(TriggerAction::Void)` / `.with_timeout_ms(..)` handle the rest. + let result: Value = iii + .trigger(TriggerRequest::new("greet", json!({ "name": "world" }))) + .await?; + + println!("result: {result}"); + iii.shutdown(); + Ok(()) +} diff --git a/sdk/packages/rust/iii/src/error.rs b/sdk/packages/rust/iii/src/error.rs index f45f593120..a52df2e49e 100644 --- a/sdk/packages/rust/iii/src/error.rs +++ b/sdk/packages/rust/iii/src/error.rs @@ -5,7 +5,12 @@ use thiserror::Error; /// Errors returned by the III SDK. #[derive(Debug, Error, Clone, Serialize, JsonSchema)] pub enum IIIError { - #[error("iii is not connected")] + #[error( + "iii is not connected: engine unreachable. Verify the engine is running \ + (`iii --config config.yaml`) and that the WebSocket URL passed to \ + `register_worker` matches (default: ws://localhost:49134). \ + See https://iii.dev/docs/install" + )] NotConnected, #[error("invocation timed out")] Timeout, diff --git a/sdk/packages/rust/iii/src/protocol.rs b/sdk/packages/rust/iii/src/protocol.rs index baee6176f9..1dcd65420e 100644 --- a/sdk/packages/rust/iii/src/protocol.rs +++ b/sdk/packages/rust/iii/src/protocol.rs @@ -106,6 +106,45 @@ pub struct TriggerRequest { pub timeout_ms: Option, } +impl TriggerRequest { + /// Build a synchronous `TriggerRequest` for the given function id and payload. + /// Equivalent to `TriggerRequest { function_id, payload, action: None, timeout_ms: None }`. + /// + /// ```rust + /// # use iii_sdk::{TriggerRequest}; + /// # use serde_json::json; + /// let req = TriggerRequest::new("math::add", json!({ "a": 1, "b": 2 })); + /// ``` + pub fn new(function_id: impl Into, payload: Value) -> Self { + Self { + function_id: function_id.into(), + payload, + action: None, + timeout_ms: None, + } + } + + /// Attach a [`TriggerAction`] (e.g., `TriggerAction::Void` for fire-and-forget, + /// `TriggerAction::Enqueue { queue }` for async queued routing). + /// + /// ```rust + /// # use iii_sdk::{TriggerRequest, TriggerAction}; + /// # use serde_json::json; + /// let req = TriggerRequest::new("analytics::track", json!({ "event": "view" })) + /// .with_action(TriggerAction::Void); + /// ``` + pub fn with_action(mut self, action: TriggerAction) -> Self { + self.action = Some(action); + self + } + + /// Override the default invocation timeout (milliseconds). + pub fn with_timeout_ms(mut self, timeout_ms: u64) -> Self { + self.timeout_ms = Some(timeout_ms); + self + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "lowercase")] pub enum Message { diff --git a/sdk/packages/rust/iii/tests/ergonomics.rs b/sdk/packages/rust/iii/tests/ergonomics.rs new file mode 100644 index 0000000000..beb7a12b16 --- /dev/null +++ b/sdk/packages/rust/iii/tests/ergonomics.rs @@ -0,0 +1,67 @@ +//! Unit tests for the small ergonomic helpers on protocol types. +//! These do not touch the engine — pure struct construction. + +use iii_sdk::{IIIError, TriggerAction, TriggerRequest}; +use serde_json::json; + +#[test] +fn not_connected_error_is_actionable() { + let msg = IIIError::NotConnected.to_string(); + assert!(msg.contains("iii is not connected"), "message: {msg}"); + assert!(msg.contains("engine unreachable"), "message: {msg}"); + assert!(msg.contains("register_worker"), "message: {msg}"); + assert!(msg.contains("ws://localhost:49134"), "message: {msg}"); + assert!( + msg.contains("https://iii.dev/docs/install"), + "message: {msg}" + ); +} + +#[test] +fn trigger_request_new_sets_required_fields_and_defaults() { + let req = TriggerRequest::new("math::add", json!({ "a": 1, "b": 2 })); + assert_eq!(req.function_id, "math::add"); + assert_eq!(req.payload, json!({ "a": 1, "b": 2 })); + assert!(req.action.is_none()); + assert!(req.timeout_ms.is_none()); +} + +#[test] +fn trigger_request_new_accepts_string_and_str() { + let a = TriggerRequest::new("greet", json!({})); + let b = TriggerRequest::new(String::from("greet"), json!({})); + assert_eq!(a.function_id, b.function_id); +} + +#[test] +fn trigger_request_with_action_void() { + let req = TriggerRequest::new("notify", json!({})).with_action(TriggerAction::Void); + assert!(matches!(req.action, Some(TriggerAction::Void))); + assert!(req.timeout_ms.is_none()); +} + +#[test] +fn trigger_request_with_action_enqueue() { + let req = TriggerRequest::new("orders::process", json!({ "id": 1 })) + .with_action(TriggerAction::Enqueue { queue: "payments".into() }); + match req.action { + Some(TriggerAction::Enqueue { queue }) => assert_eq!(queue, "payments"), + _ => panic!("expected Enqueue action"), + } +} + +#[test] +fn trigger_request_with_timeout_ms_sets_field() { + let req = TriggerRequest::new("slow::job", json!({})).with_timeout_ms(15_000); + assert_eq!(req.timeout_ms, Some(15_000)); +} + +#[test] +fn trigger_request_builder_is_chainable() { + let req = TriggerRequest::new("x", json!({})) + .with_action(TriggerAction::Void) + .with_timeout_ms(5_000); + assert_eq!(req.function_id, "x"); + assert!(matches!(req.action, Some(TriggerAction::Void))); + assert_eq!(req.timeout_ms, Some(5_000)); +}