Current section
Files
Jump to
Current section
Files
test/py_context_process_SUITE.erl
%%% @doc Common Test suite for py_context process-per-context module.
%%%
%%% Tests the process-per-context architecture where each Erlang process
%%% owns a Python context (subinterpreter or worker).
-module(py_context_process_SUITE).
-include_lib("common_test/include/ct.hrl").
-export([
all/0,
groups/0,
init_per_suite/1,
end_per_suite/1,
init_per_group/2,
end_per_group/2
]).
-export([
test_context_start_stop/1,
test_context_call/1,
test_context_eval/1,
test_context_exec/1,
test_context_isolation/1,
test_context_module_caching/1,
test_context_under_supervisor/1,
test_multiple_contexts/1,
test_context_parallel_calls/1,
test_context_timeout/1,
test_context_error_handling/1,
test_context_type_conversions/1
]).
%% ============================================================================
%% Common Test callbacks
%% ============================================================================
all() ->
[
{group, worker},
{group, subinterp}
].
groups() ->
Tests = [
test_context_start_stop,
test_context_call,
test_context_eval,
test_context_exec,
test_context_isolation,
test_context_module_caching,
test_context_under_supervisor,
test_multiple_contexts,
test_context_parallel_calls,
test_context_timeout,
test_context_error_handling,
test_context_type_conversions
],
[
{worker, [sequence], Tests},
{subinterp, [sequence], Tests}
].
init_per_suite(Config) ->
%% Ensure the application is started
application:ensure_all_started(erlang_python),
Config.
end_per_suite(_Config) ->
ok.
init_per_group(worker, Config) ->
[{context_type, worker} | Config];
init_per_group(subinterp, Config) ->
case py_nif:subinterp_supported() of
true ->
[{context_type, subinterp} | Config];
false ->
{skip, "Subinterpreters not supported (requires Python 3.12+)"}
end.
end_per_group(_Group, _Config) ->
ok.
%% ============================================================================
%% Test cases
%% ============================================================================
%% @doc Test that a context can be started and stopped.
test_context_start_stop(Config) ->
Mode = ?config(context_type, Config),
{ok, Ctx} = py_context:start_link(1, Mode),
true = is_process_alive(Ctx),
ok = py_context:stop(Ctx),
timer:sleep(50),
false = is_process_alive(Ctx).
%% @doc Test basic Python function calls.
test_context_call(Config) ->
Mode = ?config(context_type, Config),
{ok, Ctx} = py_context:start_link(1, Mode),
try
%% Test math.sqrt
{ok, 4.0} = py_context:call(Ctx, math, sqrt, [16], #{}),
%% Test with kwargs
{ok, _} = py_context:call(Ctx, json, dumps, [[{<<"a">>, 1}]], #{indent => 2}),
%% Test len function
{ok, 3} = py_context:call(Ctx, builtins, len, [[1, 2, 3]], #{})
after
py_context:stop(Ctx)
end.
%% @doc Test Python expression evaluation.
test_context_eval(Config) ->
Mode = ?config(context_type, Config),
{ok, Ctx} = py_context:start_link(1, Mode),
try
%% Simple arithmetic
{ok, 6} = py_context:eval(Ctx, <<"2 + 4">>, #{}),
%% With locals
{ok, 15} = py_context:eval(Ctx, <<"x * 3">>, #{x => 5}),
%% List comprehension
{ok, [1, 4, 9, 16]} = py_context:eval(Ctx, <<"[i*i for i in range(1, 5)]">>, #{})
after
py_context:stop(Ctx)
end.
%% @doc Test Python statement execution.
test_context_exec(Config) ->
Mode = ?config(context_type, Config),
{ok, Ctx} = py_context:start_link(1, Mode),
try
%% Execute statements
ok = py_context:exec(Ctx, <<"x = 42">>),
{ok, 42} = py_context:eval(Ctx, <<"x">>, #{}),
%% Define a function
ok = py_context:exec(Ctx, <<"def add(a, b): return a + b">>),
{ok, 7} = py_context:eval(Ctx, <<"add(3, 4)">>, #{})
after
py_context:stop(Ctx)
end.
%% @doc Test that contexts are isolated from each other.
test_context_isolation(Config) ->
Mode = ?config(context_type, Config),
{ok, Ctx1} = py_context:start_link(1, Mode),
{ok, Ctx2} = py_context:start_link(2, Mode),
try
%% Set different values in each context
ok = py_context:exec(Ctx1, <<"isolation_var = 'ctx1'">>),
ok = py_context:exec(Ctx2, <<"isolation_var = 'ctx2'">>),
%% Verify isolation
{ok, <<"ctx1">>} = py_context:eval(Ctx1, <<"isolation_var">>, #{}),
{ok, <<"ctx2">>} = py_context:eval(Ctx2, <<"isolation_var">>, #{}),
%% Verify interpreter IDs are different
{ok, Id1} = py_context:get_interp_id(Ctx1),
{ok, Id2} = py_context:get_interp_id(Ctx2),
true = Id1 =/= Id2
after
py_context:stop(Ctx1),
py_context:stop(Ctx2)
end.
%% @doc Test that modules are cached within a context.
test_context_module_caching(Config) ->
Mode = ?config(context_type, Config),
{ok, Ctx} = py_context:start_link(1, Mode),
try
%% First call imports the module
{ok, 4.0} = py_context:call(Ctx, math, sqrt, [16], #{}),
%% Second call should use cached module (faster)
{ok, 5.0} = py_context:call(Ctx, math, sqrt, [25], #{}),
%% Multiple different modules
{ok, _} = py_context:call(Ctx, json, dumps, [[1, 2, 3]], #{}),
{ok, _} = py_context:call(Ctx, os, getcwd, [], #{})
after
py_context:stop(Ctx)
end.
%% @doc Test contexts under the supervisor.
test_context_under_supervisor(Config) ->
Mode = ?config(context_type, Config),
%% Start supervisor if not already running
case whereis(py_context_sup) of
undefined ->
{ok, _SupPid} = py_context_sup:start_link();
_ ->
ok
end,
try
%% Start contexts via supervisor
{ok, Ctx1} = py_context_sup:start_context(1, Mode),
{ok, Ctx2} = py_context_sup:start_context(2, Mode),
%% Verify they work
{ok, 4.0} = py_context:call(Ctx1, math, sqrt, [16], #{}),
{ok, 9.0} = py_context:call(Ctx2, math, sqrt, [81], #{}),
%% Check which_contexts
Contexts = py_context_sup:which_contexts(),
true = lists:member(Ctx1, Contexts),
true = lists:member(Ctx2, Contexts),
%% Stop one via supervisor
ok = py_context_sup:stop_context(Ctx1),
timer:sleep(50),
false = is_process_alive(Ctx1),
true = is_process_alive(Ctx2),
%% Clean up
py_context_sup:stop_context(Ctx2)
after
ok
end.
%% @doc Test multiple contexts working in parallel.
test_multiple_contexts(Config) ->
Mode = ?config(context_type, Config),
NumContexts = 4,
%% Start multiple contexts
Contexts = [begin
{ok, Ctx} = py_context:start_link(N, Mode),
Ctx
end || N <- lists:seq(1, NumContexts)],
try
%% Run calls in parallel
Parent = self(),
Pids = [spawn_link(fun() ->
Results = [py_context:call(Ctx, math, sqrt, [N*N], #{})
|| N <- lists:seq(1, 10)],
Parent ! {self(), Results}
end) || Ctx <- Contexts],
%% Collect results
[receive
{Pid, Results} ->
%% Verify all calls succeeded
lists:foreach(fun({ok, _}) -> ok end, Results)
after 5000 ->
ct:fail("Timeout waiting for results")
end || Pid <- Pids]
after
[py_context:stop(Ctx) || Ctx <- Contexts]
end.
%% @doc Test parallel calls to the same context.
test_context_parallel_calls(Config) ->
Mode = ?config(context_type, Config),
{ok, Ctx} = py_context:start_link(1, Mode),
try
%% Make multiple calls from different processes
%% The context should serialize them (process-owned)
Parent = self(),
NumCalls = 20,
Pids = [spawn_link(fun() ->
Result = py_context:call(Ctx, math, sqrt, [N*N], #{}),
Parent ! {self(), N, Result}
end) || N <- lists:seq(1, NumCalls)],
Results = [receive
{Pid, N, Result} -> {N, Result}
after 5000 ->
ct:fail("Timeout")
end || Pid <- Pids],
%% Verify all returned correct values
lists:foreach(fun({N, {ok, Val}}) ->
true = abs(Val - float(N)) < 0.0001
end, Results)
after
py_context:stop(Ctx)
end.
%% @doc Test call timeout handling.
test_context_timeout(Config) ->
Mode = ?config(context_type, Config),
{ok, Ctx} = py_context:start_link(1, Mode),
try
%% Quick call should succeed
{ok, 4.0} = py_context:call(Ctx, math, sqrt, [16], #{}, 1000),
%% Slow call with short timeout should fail
%% (Can't easily test this without a slow Python function)
ok
after
py_context:stop(Ctx)
end.
%% @doc Test error handling in context calls.
test_context_error_handling(Config) ->
Mode = ?config(context_type, Config),
{ok, Ctx} = py_context:start_link(1, Mode),
try
%% Invalid module
{error, _} = py_context:call(Ctx, nonexistent_module, func, [], #{}),
%% Invalid function
{error, _} = py_context:call(Ctx, math, nonexistent_func, [], #{}),
%% Python exception
{error, _} = py_context:eval(Ctx, <<"1/0">>, #{}),
%% Syntax error in exec
{error, _} = py_context:exec(Ctx, <<"if true">>),
%% Context should still work after errors
{ok, 4.0} = py_context:call(Ctx, math, sqrt, [16], #{})
after
py_context:stop(Ctx)
end.
%% @doc Test type conversions between Erlang and Python.
test_context_type_conversions(Config) ->
Mode = ?config(context_type, Config),
{ok, Ctx} = py_context:start_link(1, Mode),
try
%% Integers
{ok, 42} = py_context:eval(Ctx, <<"x">>, #{x => 42}),
%% Floats
{ok, 3.14159} = py_context:eval(Ctx, <<"x">>, #{x => 3.14159}),
%% Strings (binaries)
{ok, <<"hello">>} = py_context:eval(Ctx, <<"x">>, #{x => <<"hello">>}),
%% Lists
{ok, [1, 2, 3]} = py_context:eval(Ctx, <<"x">>, #{x => [1, 2, 3]}),
%% Maps/Dicts
{ok, Map} = py_context:eval(Ctx, <<"x">>, #{x => #{a => 1, b => 2}}),
true = is_map(Map),
%% Booleans
{ok, true} = py_context:eval(Ctx, <<"x">>, #{x => true}),
{ok, false} = py_context:eval(Ctx, <<"x">>, #{x => false}),
%% None
{ok, none} = py_context:eval(Ctx, <<"None">>, #{})
after
py_context:stop(Ctx)
end.