From d28b9233cb15b7d408cf61a1acb2eae59d9b91b1 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Fri, 29 May 2026 18:33:51 +0200 Subject: [PATCH 1/2] Support Erlang/OTP 28 and 29 Validate build and the full Common Test suite on OTP 28 and 29 across Python 3.12-3.14. Set minimum_otp_vsn to 28 and update CI to test both releases with rebar3 3.25. Replace deprecated prefix-catch cleanup calls with try/catch to clear the new OTP 29 default warning; behavior is unchanged. --- .github/workflows/ci.yml | 46 +++++++++++++++++++------------ CHANGELOG.md | 10 +++++++ README.md | 2 +- docs/scalability.md | 18 ++++++++++++ rebar.config | 2 ++ src/erlang_python_app.erl | 4 +-- src/py_context.erl | 4 +-- src/py_context_router.erl | 8 +++--- src/py_event_loop.erl | 2 +- src/py_event_loop_pool.erl | 22 +++++++-------- src/py_import.erl | 12 ++++---- test/py_api_SUITE.erl | 4 +-- test/py_context_router_SUITE.erl | 4 +-- test/py_owngil_features_SUITE.erl | 12 ++++---- test/py_pid_send_SUITE.erl | 2 +- test/py_reentrant_SUITE.erl | 20 +++++++------- test/py_ref_SUITE.erl | 4 +-- test/py_thread_callback_SUITE.erl | 16 +++++------ 18 files changed, 116 insertions(+), 76 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7771a8..de64129 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: @@ -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') @@ -139,7 +149,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) @@ -160,8 +170,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 @@ -234,8 +244,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: | @@ -280,8 +290,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 @@ -313,8 +323,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 diff --git a/CHANGELOG.md b/CHANGELOG.md index a4c2437..cf70ec7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index e5744bc..74937d9 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/docs/scalability.md b/docs/scalability.md index cc32347..a725af7 100644 --- a/docs/scalability.md +++ b/docs/scalability.md @@ -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 diff --git a/rebar.config b/rebar.config index 0f5a0a3..faa0155 100644 --- a/rebar.config +++ b/rebar.config @@ -1,5 +1,7 @@ {erl_opts, [debug_info]}. +{minimum_otp_vsn, "28"}. + {xref_checks, [ undefined_function_calls, undefined_functions, diff --git a/src/erlang_python_app.erl b/src/erlang_python_app.erl index bc1115a..2485d70 100644 --- a/src/erlang_python_app.erl +++ b/src/erlang_python_app.erl @@ -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. diff --git a/src/py_context.erl b/src/py_context.erl index 83b8e79..a06ab78 100644 --- a/src/py_context.erl +++ b/src/py_context.erl @@ -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. %% ============================================================================ diff --git a/src/py_context_router.erl b/src/py_context_router.erl index 67fea1f..06e6e27 100644 --- a/src/py_context_router.erl +++ b/src/py_context_router.erl @@ -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 ) @@ -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. diff --git a/src/py_event_loop.erl b/src/py_event_loop.erl index b89bda1..0121cff 100644 --- a/src/py_event_loop.erl +++ b/src/py_event_loop.erl @@ -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) -> diff --git a/src/py_event_loop_pool.erl b/src/py_event_loop_pool.erl index 66ca0a3..4d0d16b 100644 --- a/src/py_event_loop_pool.erl +++ b/src/py_event_loop_pool.erl @@ -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 -> @@ -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, @@ -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. %%% ============================================================================ @@ -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}] -> diff --git a/src/py_import.erl b/src/py_import.erl index d4c2f3f..b470d96 100644 --- a/src/py_import.erl +++ b/src/py_import.erl @@ -404,7 +404,7 @@ 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 @@ -412,7 +412,7 @@ apply_import_to_interpreters(ModuleBin) -> {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 ); @@ -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() ); @@ -449,7 +449,7 @@ 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 @@ -457,7 +457,7 @@ apply_path_to_interpreters(PathBin) -> {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 ); @@ -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() ); diff --git a/test/py_api_SUITE.erl b/test/py_api_SUITE.erl index 3e8c49f..389ded9 100644 --- a/test/py_api_SUITE.erl +++ b/test/py_api_SUITE.erl @@ -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. %% ============================================================================ diff --git a/test/py_context_router_SUITE.erl b/test/py_context_router_SUITE.erl index 10d03e2..1ee1d8f 100644 --- a/test/py_context_router_SUITE.erl +++ b/test/py_context_router_SUITE.erl @@ -49,12 +49,12 @@ end_per_suite(_Config) -> init_per_testcase(_TestCase, Config) -> %% Ensure router is stopped before each test - catch py_context_router:stop(), + try py_context_router:stop() catch _:_ -> ok end, Config. end_per_testcase(_TestCase, _Config) -> %% Clean up after each test - catch py_context_router:stop(), + try py_context_router:stop() catch _:_ -> ok end, ok. %% ============================================================================ diff --git a/test/py_owngil_features_SUITE.erl b/test/py_owngil_features_SUITE.erl index 2a45720..2a43c30 100644 --- a/test/py_owngil_features_SUITE.erl +++ b/test/py_owngil_features_SUITE.erl @@ -246,12 +246,12 @@ init_per_testcase(_TestCase, Config) -> end_per_testcase(_TestCase, _Config) -> %% Cleanup registered functions - catch py:unregister_function(owngil_double), - catch py:unregister_function(owngil_triple), - catch py:unregister_function(owngil_level), - catch py:unregister_function(owngil_transform), - catch py:unregister_function(owngil_get_value), - catch py:unregister_function(owngil_echo), + try py:unregister_function(owngil_double) catch _:_ -> ok end, + try py:unregister_function(owngil_triple) catch _:_ -> ok end, + try py:unregister_function(owngil_level) catch _:_ -> ok end, + try py:unregister_function(owngil_transform) catch _:_ -> ok end, + try py:unregister_function(owngil_get_value) catch _:_ -> ok end, + try py:unregister_function(owngil_echo) catch _:_ -> ok end, ok. %%% ============================================================================ diff --git a/test/py_pid_send_SUITE.erl b/test/py_pid_send_SUITE.erl index 45dc33d..5497f3b 100644 --- a/test/py_pid_send_SUITE.erl +++ b/test/py_pid_send_SUITE.erl @@ -102,7 +102,7 @@ init_per_testcase(_TestCase, Config) -> Config. end_per_testcase(_TestCase, _Config) -> - catch py:unregister_function(test_pid_echo), + try py:unregister_function(test_pid_echo) catch _:_ -> ok end, ok. %%% ============================================================================ diff --git a/test/py_reentrant_SUITE.erl b/test/py_reentrant_SUITE.erl index 0db9909..9d3818a 100644 --- a/test/py_reentrant_SUITE.erl +++ b/test/py_reentrant_SUITE.erl @@ -55,16 +55,16 @@ init_per_testcase(_TestCase, Config) -> end_per_testcase(_TestCase, _Config) -> %% Cleanup any registered functions - catch py:unregister_function(double_via_python), - catch py:unregister_function(triple), - catch py:unregister_function(call_level), - catch py:unregister_function(may_fail), - catch py:unregister_function(transform), - catch py:unregister_function(add_ten), - catch py:unregister_function(multiply_by_two), - catch py:unregister_function(subtract_five), - catch py:unregister_function(async_multiply), - catch py:unregister_function(test_registry_func), + try py:unregister_function(double_via_python) catch _:_ -> ok end, + try py:unregister_function(triple) catch _:_ -> ok end, + try py:unregister_function(call_level) catch _:_ -> ok end, + try py:unregister_function(may_fail) catch _:_ -> ok end, + try py:unregister_function(transform) catch _:_ -> ok end, + try py:unregister_function(add_ten) catch _:_ -> ok end, + try py:unregister_function(multiply_by_two) catch _:_ -> ok end, + try py:unregister_function(subtract_five) catch _:_ -> ok end, + try py:unregister_function(async_multiply) catch _:_ -> ok end, + try py:unregister_function(test_registry_func) catch _:_ -> ok end, ok. %%% ============================================================================ diff --git a/test/py_ref_SUITE.erl b/test/py_ref_SUITE.erl index bd0f174..853b8e6 100644 --- a/test/py_ref_SUITE.erl +++ b/test/py_ref_SUITE.erl @@ -46,12 +46,12 @@ end_per_suite(_Config) -> init_per_testcase(_TestCase, Config) -> %% Start contexts for testing - catch py:stop_contexts(), + try py:stop_contexts() catch _:_ -> ok end, {ok, _} = py:start_contexts(#{contexts => 2}), Config. end_per_testcase(_TestCase, _Config) -> - catch py:stop_contexts(), + try py:stop_contexts() catch _:_ -> ok end, ok. %% ============================================================================ diff --git a/test/py_thread_callback_SUITE.erl b/test/py_thread_callback_SUITE.erl index a656bc3..3e66492 100644 --- a/test/py_thread_callback_SUITE.erl +++ b/test/py_thread_callback_SUITE.erl @@ -63,14 +63,14 @@ init_per_testcase(_TestCase, Config) -> end_per_testcase(_TestCase, _Config) -> %% Cleanup any registered functions - catch py:unregister_function(double_it), - catch py:unregister_function(add_one), - catch py:unregister_function(call_python_square), - catch py:unregister_function(square_in_erlang), - catch py:unregister_function(maybe_fail), - catch py:unregister_function(get_id), - catch py:unregister_function(async_double), - catch py:unregister_function(async_blob), + try py:unregister_function(double_it) catch _:_ -> ok end, + try py:unregister_function(add_one) catch _:_ -> ok end, + try py:unregister_function(call_python_square) catch _:_ -> ok end, + try py:unregister_function(square_in_erlang) catch _:_ -> ok end, + try py:unregister_function(maybe_fail) catch _:_ -> ok end, + try py:unregister_function(get_id) catch _:_ -> ok end, + try py:unregister_function(async_double) catch _:_ -> ok end, + try py:unregister_function(async_blob) catch _:_ -> ok end, ok. %%% ============================================================================ From de64b032d80a818a3ce007bf124d452774a47fca Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Fri, 29 May 2026 19:27:14 +0200 Subject: [PATCH 2/2] ci(freebsd): use erlang-runtime28, test Python 3.12/3.13 Stock FreeBSD erlang pkg ships OTP 26, below the OTP 28 minimum, so rebar3 refused to build. Install erlang-runtime28 and add its bin dir to PATH. Drop Python 3.11 (below the 3.12 minimum). --- .github/workflows/ci.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de64129..b74fc9a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,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 @@ -129,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-numpy convention. # Non-fatal: not every FreeBSD pkg snapshot ships numpy for # every Python flavor; the ML SUITE self-skips when numpy @@ -140,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:"