Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions .github/workflows/sdk-readme.yml
Original file line number Diff line number Diff line change
@@ -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
135 changes: 135 additions & 0 deletions scripts/readme-guard/check_python_readme.py
Original file line number Diff line number Diff line change
@@ -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.<name>(...)` 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
Comment thread
coderabbitai[bot] marked this conversation as resolved.


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())
69 changes: 44 additions & 25 deletions sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -47,58 +57,67 @@ 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"}})
```

### 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<dyn std::error::Error>> {
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::<Value, iii_sdk::IIIError>(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.

Expand Down
17 changes: 17 additions & 0 deletions sdk/fixtures/readme-guard/golden-bad-python.md
Original file line number Diff line number Diff line change
@@ -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
```
23 changes: 23 additions & 0 deletions sdk/fixtures/readme-guard/golden-good-python.md
Original file line number Diff line number Diff line change
@@ -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"}})
```
Loading
Loading