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
58 changes: 36 additions & 22 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,25 +15,35 @@ jobs:
fail-fast: false
matrix:
include:
# Linux with different OTP/Python combinations
# Linux - OTP 28
- os: ubuntu-24.04
otp: "27.0"
otp: "28"
python: "3.12"
- os: ubuntu-24.04
otp: "27.0"
otp: "28"
python: "3.13"
- os: ubuntu-24.04
otp: "27.0"
otp: "28"
python: "3.14"
# macOS
# Linux - OTP 29
- os: ubuntu-24.04
otp: "29"
python: "3.12"
- os: ubuntu-24.04
otp: "29"
python: "3.13"
- os: ubuntu-24.04
otp: "29"
python: "3.14"
# macOS (brew installs the latest OTP)
- os: macos-15
otp: "27"
otp: "29"
python: "3.12"
- os: macos-15
otp: "27"
otp: "29"
python: "3.13"
- os: macos-15
otp: "27"
otp: "29"
python: "3.14"

steps:
Expand All @@ -50,7 +60,7 @@ jobs:
uses: erlef/setup-beam@v1
with:
otp-version: ${{ matrix.otp }}
rebar3-version: "3.24"
rebar3-version: "3.25"

- name: Set up Erlang (macOS)
if: startsWith(matrix.os, 'macos')
Expand Down Expand Up @@ -104,10 +114,10 @@ jobs:
fail-fast: false
matrix:
include:
- python: "3.11"
python_pkg: "python311"
- python: "3.12"
python_pkg: "python312"
- python: "3.13"
python_pkg: "python313"

steps:
- name: Checkout
Expand All @@ -119,7 +129,9 @@ jobs:
release: "14.1"
usesh: true
prepare: |
pkg install -y erlang ${{ matrix.python_pkg }} cmake
# Stock `erlang` pkg lags at OTP 26; use the versioned runtime
# package so we test on a supported OTP (28+).
pkg install -y erlang-runtime28 ${{ matrix.python_pkg }} cmake
# numpy package follows the py<noDot>-numpy convention.
# Non-fatal: not every FreeBSD pkg snapshot ships numpy for
# every Python flavor; the ML SUITE self-skips when numpy
Expand All @@ -130,7 +142,9 @@ jobs:
run: |
# Set up environment
export PYTHON_CONFIG=python${{ matrix.python }}-config
export PATH="/usr/local/bin:$PATH"
# erlang-runtime28 installs outside the default PATH; add its bin dir.
ERL_BIN="$(dirname "$(pkg info -l erlang-runtime28 | tr -d '[:blank:]' | grep -E '/bin/erl$' | grep -v 'erts-' | head -1)")"
export PATH="$ERL_BIN:/usr/local/bin:$PATH"

# Verify versions
echo "Erlang version:"
Expand All @@ -139,7 +153,7 @@ jobs:
python${{ matrix.python }} --version

# Build with rebar3
fetch https://github.com/erlang/rebar3/releases/download/3.24.0/rebar3 -o rebar3
fetch https://github.com/erlang/rebar3/releases/download/3.25.0/rebar3 -o rebar3
chmod +x rebar3

# Compile and test (uses CMake-based build)
Expand All @@ -160,8 +174,8 @@ jobs:
- name: Set up Erlang
uses: erlef/setup-beam@v1
with:
otp-version: "27.0"
rebar3-version: "3.24"
otp-version: "29"
rebar3-version: "3.25"

- name: Set up free-threaded Python
uses: actions/setup-python@v5
Expand Down Expand Up @@ -234,8 +248,8 @@ jobs:
- name: Set up Erlang
uses: erlef/setup-beam@v1
with:
otp-version: "27.0"
rebar3-version: "3.24"
otp-version: "29"
rebar3-version: "3.25"

- name: Install dependencies
run: |
Expand Down Expand Up @@ -280,8 +294,8 @@ jobs:
- name: Set up Erlang
uses: erlef/setup-beam@v1
with:
otp-version: "27.0"
rebar3-version: "3.24"
otp-version: "29"
rebar3-version: "3.25"

- name: Set up Python
uses: actions/setup-python@v5
Expand Down Expand Up @@ -313,8 +327,8 @@ jobs:
- name: Set up Erlang
uses: erlef/setup-beam@v1
with:
otp-version: "27.0"
rebar3-version: "3.24"
otp-version: "29"
rebar3-version: "3.25"

- name: Set up Python
uses: actions/setup-python@v5
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## Unreleased

### Changed

- **Support Erlang/OTP 28 and 29** - Validated builds and the full Common Test
suite on OTP 28 and 29. Minimum supported OTP is now 28 (`minimum_otp_vsn`).
CI tests OTP 28 and 29 across Python 3.12/3.13/3.14.
- Replaced deprecated `catch Expr` cleanup calls with `try ... catch ... end`
to silence the new OTP 29 default warning; behavior is unchanged.

## 3.0.0 (2026-05-03)

### Breaking Changes
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Key features:

## Requirements

- Erlang/OTP 27+
- Erlang/OTP 28+ (tested on OTP 28 and 29)
- Python 3.12+ (3.13+ for free-threading)
- C compiler (gcc, clang)

Expand Down
18 changes: 18 additions & 0 deletions docs/scalability.md
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,24 @@ The `PERF_BUILD` option enables:
{ok, 2}
```

### Erlang/OTP Version

erlang_python is built and tested on **Erlang/OTP 28 and 29** (minimum OTP 28).
The heavy lifting happens in the C NIF, so the BEAM-side glue is light, but newer
OTP releases still help for free:

- **OTP 29 JIT and scheduler improvements** apply to the Erlang side (routing,
context bookkeeping, callback dispatch) with no code change. Just run on OTP 29.
- **Consistent map iteration order** is now guaranteed across map operations in
OTP 29. The router and state modules do not depend on iteration order, so this
changes nothing here, but it removes a class of subtle bugs if you build on top.
- **Building the NIF stays the same** across OTP 28/29 (NIF API 2.18); no version
guards are needed in `c_src`.

For doc-example testing, OTP 29 ships `ct_doctest`. This project keeps its own
`scripts/lint_doc_snippets.escript` because that linter also validates the Python
blocks in the docs, which `ct_doctest` does not cover.

## See Also

- [Getting Started](getting-started.md) - Basic usage
Expand Down
2 changes: 2 additions & 0 deletions rebar.config
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
{erl_opts, [debug_info]}.

{minimum_otp_vsn, "28"}.

{xref_checks, [
undefined_function_calls,
undefined_functions,
Expand Down
4 changes: 2 additions & 2 deletions src/erlang_python_app.erl
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,6 @@ start(_StartType, _StartArgs) ->
stop(_State) ->
%% Stop pools before application shutdown to ensure proper cleanup
%% of subinterpreters before NIF resources are garbage collected
catch py_context_router:stop_pool(io),
catch py_context_router:stop_pool(default),
try py_context_router:stop_pool(io) catch _:_ -> ok end,
try py_context_router:stop_pool(default) catch _:_ -> ok end,
ok.
4 changes: 2 additions & 2 deletions src/py_context.erl
Original file line number Diff line number Diff line change
Expand Up @@ -651,12 +651,12 @@ terminate(_Reason, #state{ref = Ref, event_state = EventState, callback_handler
%% Stop the event worker first (if it exists and is still alive)
case EventState of
#{worker_pid := WorkerPid} ->
catch gen_server:stop(WorkerPid, normal, 5000);
try gen_server:stop(WorkerPid, normal, 5000) catch _:_ -> ok end;
_ ->
ok
end,
%% Destroy the Python context
catch py_nif:context_destroy(Ref),
try py_nif:context_destroy(Ref) catch _:_ -> ok end,
ok.

%% ============================================================================
Expand Down
8 changes: 4 additions & 4 deletions src/py_context_router.erl
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,7 @@ stop_pool(Pool) when is_atom(Pool) ->
Contexts ->
lists:foreach(
fun(Ctx) ->
catch py_context_sup:stop_context(Ctx)
try py_context_sup:stop_context(Ctx) catch _:_ -> ok end
end,
Contexts
)
Expand All @@ -365,12 +365,12 @@ stop_pool(Pool) when is_atom(Pool) ->
Size = persistent_term:get(?POOL_SIZE_KEY(Pool), 0),
lists:foreach(
fun(N) ->
catch persistent_term:erase(?POOL_CONTEXT_KEY(Pool, N))
try persistent_term:erase(?POOL_CONTEXT_KEY(Pool, N)) catch _:_ -> ok end
end,
lists:seq(1, Size)
),
catch persistent_term:erase(?POOL_SIZE_KEY(Pool)),
catch persistent_term:erase(?POOL_CONTEXTS_KEY(Pool)),
try persistent_term:erase(?POOL_SIZE_KEY(Pool)) catch _:_ -> ok end,
try persistent_term:erase(?POOL_CONTEXTS_KEY(Pool)) catch _:_ -> ok end,
ok.

%% @doc Check if a pool has been started and is still alive.
Expand Down
2 changes: 1 addition & 1 deletion src/py_event_loop.erl
Original file line number Diff line number Diff line change
Expand Up @@ -442,7 +442,7 @@ import sys, asyncio
if sys.version_info < (3, 14):
asyncio.set_event_loop_policy(None)
">>,
catch py:exec(Code),
try py:exec(Code) catch _:_ -> ok end,
ok.

code_change(_OldVsn, State, _Extra) ->
Expand Down
22 changes: 11 additions & 11 deletions src/py_event_loop_pool.erl
Original file line number Diff line number Diff line change
Expand Up @@ -510,7 +510,7 @@ handle_info({'DOWN', MonRef, process, Pid, _Reason}, State) ->
case SessionPid =:= Pid andalso SessionMonRef =:= MonRef of
true ->
%% Destroy session in worker
catch py_nif:owngil_destroy_session(WorkerId, HandleId),
try py_nif:owngil_destroy_session(WorkerId, HandleId) catch _:_ -> ok end,
%% Remove from ETS
ets:delete(Tid, Key);
false ->
Expand All @@ -531,15 +531,15 @@ terminate(_Reason, State) ->
Tid ->
%% Destroy all sessions
ets:foldl(fun(#owngil_session{worker_id = WorkerId, handle_id = HandleId}, _) ->
catch py_nif:owngil_destroy_session(WorkerId, HandleId),
try py_nif:owngil_destroy_session(WorkerId, HandleId) catch _:_ -> ok end,
ok
end, ok, Tid),
catch ets:delete(Tid)
try ets:delete(Tid) catch _:_ -> ok end
end,

%% Stop OWN_GIL thread pool if it was started
case State#state.owngil_enabled of
true -> catch py_nif:subinterp_thread_pool_stop();
true -> try py_nif:subinterp_thread_pool_stop() catch _:_ -> ok end;
false -> ok
end,

Expand All @@ -548,15 +548,15 @@ terminate(_Reason, State) ->
{} -> ok;
Loops ->
lists:foreach(fun({LoopRef, WorkerPid}) ->
catch py_event_worker:stop(WorkerPid),
catch py_nif:event_loop_destroy(LoopRef)
try py_event_worker:stop(WorkerPid) catch _:_ -> ok end,
try py_nif:event_loop_destroy(LoopRef) catch _:_ -> ok end
end, tuple_to_list(Loops))
end,

catch persistent_term:erase(?PT_LOOPS),
catch persistent_term:erase(?PT_NUM_LOOPS),
catch persistent_term:erase(?PT_OWNGIL_ENABLED),
catch persistent_term:erase(?PT_SESSIONS),
try persistent_term:erase(?PT_LOOPS) catch _:_ -> ok end,
try persistent_term:erase(?PT_NUM_LOOPS) catch _:_ -> ok end,
try persistent_term:erase(?PT_OWNGIL_ENABLED) catch _:_ -> ok end,
try persistent_term:erase(?PT_SESSIONS) catch _:_ -> ok end,
ok.

%%% ============================================================================
Expand Down Expand Up @@ -638,7 +638,7 @@ create_session(Tid, Pid, LoopIdx) ->
false ->
%% Another process created the session first, destroy ours
erlang:demonitor(MonRef, [flush]),
catch py_nif:owngil_destroy_session(WorkerId, HandleId),
try py_nif:owngil_destroy_session(WorkerId, HandleId) catch _:_ -> ok end,
%% Retry lookup
case ets:lookup(Tid, {Pid, LoopIdx}) of
[#owngil_session{worker_id = W, handle_id = H}] ->
Expand Down
12 changes: 6 additions & 6 deletions src/py_import.erl
Original file line number Diff line number Diff line change
Expand Up @@ -404,15 +404,15 @@ apply_import_to_interpreters(ModuleBin) ->
%% Apply to main event loop
case py_event_loop:get_loop() of
{ok, LoopRef} ->
catch py_nif:interp_apply_imports(LoopRef, Imports);
try py_nif:interp_apply_imports(LoopRef, Imports) catch _:_ -> ok end;
_ -> ok
end,
%% Apply to all pool event loops
case py_event_loop_pool:get_all_loops() of
{ok, Loops} ->
lists:foreach(
fun({LoopRef, _WorkerPid}) ->
catch py_nif:interp_apply_imports(LoopRef, Imports)
try py_nif:interp_apply_imports(LoopRef, Imports) catch _:_ -> ok end
end,
Loops
);
Expand All @@ -423,7 +423,7 @@ apply_import_to_interpreters(ModuleBin) ->
true ->
lists:foreach(
fun({WorkerId, HandleId}) ->
catch py_nif:owngil_apply_imports(WorkerId, HandleId, Imports)
try py_nif:owngil_apply_imports(WorkerId, HandleId, Imports) catch _:_ -> ok end
end,
py_event_loop_pool:get_all_sessions()
);
Expand All @@ -449,15 +449,15 @@ apply_path_to_interpreters(PathBin) ->
%% Apply to main event loop
case py_event_loop:get_loop() of
{ok, LoopRef} ->
catch py_nif:interp_apply_paths(LoopRef, Paths);
try py_nif:interp_apply_paths(LoopRef, Paths) catch _:_ -> ok end;
_ -> ok
end,
%% Apply to all pool event loops
case py_event_loop_pool:get_all_loops() of
{ok, Loops} ->
lists:foreach(
fun({LoopRef, _WorkerPid}) ->
catch py_nif:interp_apply_paths(LoopRef, Paths)
try py_nif:interp_apply_paths(LoopRef, Paths) catch _:_ -> ok end
end,
Loops
);
Expand All @@ -468,7 +468,7 @@ apply_path_to_interpreters(PathBin) ->
true ->
lists:foreach(
fun({WorkerId, HandleId}) ->
catch py_nif:owngil_apply_paths(WorkerId, HandleId, Paths)
try py_nif:owngil_apply_paths(WorkerId, HandleId, Paths) catch _:_ -> ok end
end,
py_event_loop_pool:get_all_sessions()
);
Expand Down
4 changes: 2 additions & 2 deletions test/py_api_SUITE.erl
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,13 @@ end_per_suite(_Config) ->

init_per_testcase(_TestCase, Config) ->
%% Ensure fresh contexts are available for each test
catch py:stop_contexts(),
try py:stop_contexts() catch _:_ -> ok end,
{ok, _} = py:start_contexts(),
Config.

end_per_testcase(_TestCase, _Config) ->
%% Clean up after each test
catch py:stop_contexts(),
try py:stop_contexts() catch _:_ -> ok end,
ok.

%% ============================================================================
Expand Down
Loading
Loading