Skip to content
Merged
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
10 changes: 5 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
strategy:
fail-fast: false
matrix:
otp: ['27.0', '27.1', '28.0']
otp: ['27.0', '27.1', '28.0', '29']
python: ['3.12', '3.13', '3.14']

steps:
Expand All @@ -24,7 +24,7 @@ jobs:
uses: erlef/setup-beam@v1
with:
otp-version: ${{ matrix.otp }}
rebar3-version: '3.24'
rebar3-version: '3.27.0'

- name: Set up Python
uses: actions/setup-python@v5
Expand Down Expand Up @@ -66,8 +66,8 @@ jobs:
- name: Set up Erlang
uses: erlef/setup-beam@v1
with:
otp-version: '28.0'
rebar3-version: '3.24'
otp-version: '29'
rebar3-version: '3.27.0'

- name: Build ex_doc
run: rebar3 ex_doc
Expand All @@ -83,7 +83,7 @@ jobs:
uses: erlef/setup-beam@v1
with:
otp-version: '28.0'
rebar3-version: '3.24'
rebar3-version: '3.27.0'

- name: Compile
run: rebar3 compile
Expand Down
55 changes: 55 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,61 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **erlang_python v3.0**: Track the simplified execution model
- Switched dep to erlang_python `main` (worker / owngil modes only)
- `config/sys.config`: replaced obsolete `num_workers` key with `num_contexts`
- Python runners (`asgi`, `lifespan`, `websocket`): import `erlang` instead of
the removed `erlang_loop` shim and skip `asyncio.set_event_loop_policy` on
Python 3.14+ (deprecated in 3.14, removed in 3.16)

### Fixed

- **Atom comparison in `hornbeam_erlang`**: result-tuple unwrap helpers
(`execute`, `execute_async`, `await_result`, `stream`) now recognise
Erlang atoms whether they surface as `erlang.Atom`, `bytes`, or `str`,
so `{ok, Value}` correctly returns `Value` instead of the raw tuple.
- **Hook function-handler signature**: `hornbeam_hooks_runner` now
spreads `*args, **kwargs` into the documented
`def handler(action, *args, **kwargs)` signature instead of passing
the args list as a single positional.
- **`hornbeam_hooks:execute_python_registered`**: pass through `{ok, _}`
/ `{error, _}` from `py:call` instead of wrapping again, so
`execute(...)` returns the handler's value (was a `{ok, Inner}` shell).
- **Stream generator storage**: `hornbeam_hooks:stream_ref/4` parks the
Erlang generator in an ETS table and returns the ref to Python (the
doc claimed Python could pass an Erlang `fun()` as a "ref"; nothing
ever populated the storage map).
- **`hornbeam_erlang.call/cast` from Python**: registered the
`hornbeam_callbacks` Python-callable dispatcher so the documented
`from hornbeam_erlang import call` path actually reaches
`hornbeam_callbacks:call/2`.
- **`hornbeam_erlang.publish` from Python**: registered a
`hornbeam_pubsub` Python-callable dispatcher so `publish(topic, msg)`
reaches `hornbeam_pubsub:publish/2` and returns the subscriber count.
- **`state_get_multi` / `state_keys` from Python**: registered a
`hornbeam_state` Python-callable dispatcher.
- **`stream` / `stream_next_ref` from Python**: routed
`hornbeam_hooks:stream/4` and `stream_next_ref/1` through the same
`hornbeam_hooks` dispatcher used for `reg_python` / `unreg`.
- **`state_get_multi` semantics**: missing keys now map to `undefined`
(rendered as Python `None`) so the documented return shape
`{'user:3': None}` matches runtime.

### Added

- `test/hornbeam_examples_smoke_SUITE.erl` — HTTP smoke test for seven
example apps (`async_chat`, `channels_chat`, `demo/realtime_chat`,
`erlang_integration`, `fastapi_app`, `hooks_lifespan`,
`websocket_chat`). FastAPI-dependent cases auto-skip if the package is
missing.
- `test/hornbeam_doc_python_api_SUITE.erl` — runs every runnable code
block from `docs/reference/python-api.md` verbatim. 16 snippets pass;
8 hook-related snippets are skipped pending a follow-up to fix nested
`py:exec` → `register_hook` callback synchronisation.
- `scripts/docker_smoke.sh` + `make docker-smoke` target — builds every
Dockerfile in the repo (root, channels-chat, demo/distributed_rpc,
demo/multi_app; ml_caching gated on `DOCKER_SMOKE_HEAVY=1`).

- **Shared Context Pool**: All mounts now share the default `py_context_router` pool
- Removed per-mount `workers` option (use global pool size instead)
- Better resource utilization across multiple mounted apps
Expand Down
12 changes: 12 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.PHONY: compile test ct docker-smoke

compile:
rebar3 compile

test ct:
rebar3 ct

# Smoke-test every Dockerfile builds. Set DOCKER_SMOKE_HEAVY=1 to also
# build the ml_caching image (slow, downloads sentence-transformers).
docker-smoke:
scripts/docker_smoke.sh
2 changes: 1 addition & 1 deletion config/sys.config
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
{wsgi_streaming_threshold, 65536} %% Stream if > 64KB
]},
{erlang_python, [
{num_workers, 4},
{num_contexts, 4},
{max_concurrent, 10000}
]}
].
8 changes: 6 additions & 2 deletions priv/hornbeam_asgi_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,13 @@
# Install erlang event loop as the default event loop policy (once per interpreter)
def _install_erlang_loop() -> bool:
"""Install erlang event loop as the default asyncio event loop policy."""
if sys.version_info >= (3, 14):
# asyncio.set_event_loop_policy is deprecated in 3.14 / removed in 3.16.
# erlang.run() and asyncio.Runner(loop_factory=...) provide the loop directly.
return False
try:
from erlang_loop import get_event_loop_policy
asyncio.set_event_loop_policy(get_event_loop_policy())
import erlang
asyncio.set_event_loop_policy(erlang.get_event_loop_policy())
return True
except (ImportError, AttributeError, RuntimeError):
return False
Expand Down
43 changes: 29 additions & 14 deletions priv/hornbeam_erlang.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,21 @@ def _call_erlang(name: str, *args) -> Any:
return erl.call(name, *args)


def _is_atom(val: Any, name: str) -> bool:
"""Compare a value to an Erlang atom name. erlang_python may surface
atoms as ``erlang.Atom``, ``bytes``, or ``str`` depending on the
path; normalise so callers don't have to."""
if isinstance(val, str):
return val == name
if isinstance(val, bytes):
return val == name.encode()
# erlang.Atom: rely on str() since rich-compare against str returns
# NotImplemented (atoms only compare equal to other atoms).
if HAS_ERLANG and isinstance(val, getattr(erl, 'Atom', tuple())):
return str(val) == name
return False


# =============================================================================
# Hook Registration API
# =============================================================================
Expand Down Expand Up @@ -139,9 +154,9 @@ def execute(app_path: str, action: str, *args, **kwargs) -> Any:
result = _call_erlang('hornbeam_hooks_execute',
app_path, action, list(args), kwargs)
if isinstance(result, tuple):
if result[0] == 'ok':
if _is_atom(result[0], 'ok'):
return result[1]
if result[0] == 'error':
if _is_atom(result[0], 'error'):
raise RuntimeError(f"Execute failed: {result[1]}")
return result

Expand All @@ -168,9 +183,9 @@ def execute_async(app_path: str, action: str, *args, **kwargs) -> str:
result = _call_erlang('hornbeam_hooks_execute_async',
app_path, action, list(args), kwargs)
if isinstance(result, tuple):
if result[0] == 'ok':
if _is_atom(result[0], 'ok'):
return result[1]
if result[0] == 'error':
if _is_atom(result[0], 'error'):
raise RuntimeError(f"Execute async failed: {result[1]}")
return result

Expand All @@ -190,9 +205,9 @@ def await_result(task_id: str, timeout_ms: int = 30000) -> Any:
"""
result = _call_erlang('hornbeam_hooks_await_result', task_id, timeout_ms)
if isinstance(result, tuple):
if result[0] == 'ok':
if _is_atom(result[0], 'ok'):
return result[1]
if result[0] == 'error':
if _is_atom(result[0], 'error'):
raise RuntimeError(f"Await failed: {result[1]}")
return result

Expand All @@ -219,19 +234,19 @@ def stream(app_path: str, action: str, *args, **kwargs) -> Generator[Any, None,
[app_path, action, list(args), kwargs])

if isinstance(result, tuple):
if result[0] == 'error':
if _is_atom(result[0], 'error'):
raise RuntimeError(f"Stream failed: {result[1]}")
if result[0] == 'ok':
if _is_atom(result[0], 'ok'):
# result[1] is a reference to Erlang generator function
gen_ref = result[1]
while True:
chunk = _call_erlang('hornbeam_hooks', 'stream_next_ref', [gen_ref])
if chunk == 'done':
break
if isinstance(chunk, tuple):
if chunk[0] == 'value':
if _is_atom(chunk[0], 'value'):
yield chunk[1]
elif chunk[0] == 'error':
elif _is_atom(chunk[0], 'error'):
raise RuntimeError(f"Stream error: {chunk[1]}")


Expand Down Expand Up @@ -327,9 +342,9 @@ def rpc_call(node: str, module: str, function: str, args: list,
result = _call_erlang('hornbeam_dist', 'rpc_call',
[node, module, function, args, timeout_ms])
if isinstance(result, tuple):
if result[0] == 'ok':
if _is_atom(result[0], 'ok'):
return result[1]
if result[0] == 'error':
if _is_atom(result[0], 'error'):
raise RuntimeError(f"RPC failed: {result[1]}")
return result

Expand Down Expand Up @@ -366,9 +381,9 @@ def call(name: str, *args) -> ErlValue:
"""Call registered Erlang function."""
result = _call_erlang('hornbeam_callbacks', 'call', [name, list(args)])
if isinstance(result, tuple):
if result[0] == 'ok':
if _is_atom(result[0], 'ok'):
return result[1]
if result[0] == 'error':
if _is_atom(result[0], 'error'):
raise RuntimeError(f"Call failed: {result[1]}")
return result

Expand Down
5 changes: 3 additions & 2 deletions priv/hornbeam_hooks_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,9 +248,10 @@ def execute_registered(app_path: str, action: str, args: List[Any], kwargs: Dict
method = getattr(handler, action)
return method(*args, **kwargs)

# If handler is callable (function), call it with action
# If handler is callable (function), spread args/kwargs to match the
# documented signature `def handler(action, *args, **kwargs)`.
elif callable(handler):
return handler(action, args, kwargs)
return handler(action, *args, **kwargs)

else:
raise TypeError(f"Handler for {app_path} is not callable")
Expand Down
8 changes: 6 additions & 2 deletions priv/hornbeam_lifespan_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,13 @@
# Install erlang event loop as the default event loop policy (once per interpreter)
def _install_erlang_loop() -> bool:
"""Install erlang event loop as the default asyncio event loop policy."""
if sys.version_info >= (3, 14):
# asyncio.set_event_loop_policy is deprecated in 3.14 / removed in 3.16.
# erlang.run() and asyncio.Runner(loop_factory=...) provide the loop directly.
return False
try:
from erlang_loop import get_event_loop_policy
asyncio.set_event_loop_policy(get_event_loop_policy())
import erlang
asyncio.set_event_loop_policy(erlang.get_event_loop_policy())
return True
except (ImportError, AttributeError, RuntimeError):
return False
Expand Down
8 changes: 6 additions & 2 deletions priv/hornbeam_websocket_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,13 @@
# Install erlang event loop as the default event loop policy (once per interpreter)
def _install_erlang_loop() -> bool:
"""Install erlang event loop as the default asyncio event loop policy."""
if sys.version_info >= (3, 14):
# asyncio.set_event_loop_policy is deprecated in 3.14 / removed in 3.16.
# erlang.run() and asyncio.Runner(loop_factory=...) provide the loop directly.
return False
try:
from erlang_loop import get_event_loop_policy
asyncio.set_event_loop_policy(get_event_loop_policy())
import erlang
asyncio.set_event_loop_policy(erlang.get_event_loop_policy())
return True
except (ImportError, AttributeError, RuntimeError):
return False
Expand Down
2 changes: 1 addition & 1 deletion rebar.config
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

{deps, [
{cowboy, "2.12.0"},
{erlang_python, "2.3.0"}
{erlang_python, "3.1.1"}
]}.

{shell, [
Expand Down
65 changes: 65 additions & 0 deletions scripts/docker_smoke.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Smoke-test that every Dockerfile in the repo builds. Catches stale COPY
# paths, broken apt packages, and missing build steps; does NOT run the
# resulting containers.
#
# Usage:
# scripts/docker_smoke.sh # fast set (skips ML-heavy builds)
# DOCKER_SMOKE_HEAVY=1 scripts/docker_smoke.sh # include ml_caching
#
# Exits non-zero on the first build failure.

set -euo pipefail

if ! command -v docker >/dev/null 2>&1; then
echo "docker not found in PATH" >&2
exit 2
fi

REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$REPO_ROOT"

# tag prefix for cleanup convenience
TAG_PREFIX="hornbeam-smoke"

# Each entry: name | dockerfile | build context | extra-build-args
BUILDS=(
"root|Dockerfile|.|"
"channels-chat|examples/channels_chat/Dockerfile|.|"
"demo-distributed-rpc|examples/demo/distributed_rpc/Dockerfile|.|"
"demo-multi-app|examples/demo/multi_app/Dockerfile|.|"
)

# examples/demo/Dockerfile.demo is parameterised over EXAMPLE; ml_caching
# pre-downloads sentence-transformers (~10 min build), so gate it on the
# heavy flag. multi_app and distributed_rpc are also parameterised but
# have their own Dockerfiles above, so the demo file only adds ml_caching
# coverage.
if [[ "${DOCKER_SMOKE_HEAVY:-0}" == "1" ]]; then
BUILDS+=(
"demo-ml-caching|examples/demo/Dockerfile.demo|.|--build-arg EXAMPLE=ml_caching"
)
fi

failed=()

for entry in "${BUILDS[@]}"; do
IFS='|' read -r name dockerfile context extra <<<"$entry"
echo
echo "::: building $name ($dockerfile)"
# shellcheck disable=SC2086 # extra is intentionally word-split
if docker build -q -f "$dockerfile" -t "${TAG_PREFIX}:${name}" $extra "$context"; then
echo "::: ok"
else
echo "::: FAIL"
failed+=("$name")
fi
done

echo
if [[ ${#failed[@]} -gt 0 ]]; then
echo "Docker smoke FAILED: ${failed[*]}" >&2
exit 1
fi

echo "Docker smoke OK (${#BUILDS[@]} images built)"
Loading
Loading