Skip to content

Commit 4ac10a4

Browse files
authored
Merge pull request #47 from benoitc/feature/import-caching
Add lazy module import caching API
2 parents 1dc888e + 2424f00 commit 4ac10a4

9 files changed

Lines changed: 1642 additions & 26 deletions

File tree

c_src/py_event_loop.c

Lines changed: 497 additions & 0 deletions
Large diffs are not rendered by default.

c_src/py_event_loop.h

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -690,6 +690,62 @@ ERL_NIF_TERM nif_process_ready_tasks(ErlNifEnv *env, int argc,
690690
ERL_NIF_TERM nif_event_loop_set_py_loop(ErlNifEnv *env, int argc,
691691
const ERL_NIF_TERM argv[]);
692692

693+
/* ============================================================================
694+
* Module Import Caching
695+
* ============================================================================ */
696+
697+
/**
698+
* @brief Import and cache a module in the event loop's interpreter
699+
*
700+
* Pre-imports the module and caches it for faster subsequent calls.
701+
* The __main__ module is never cached (returns error).
702+
*
703+
* NIF: loop_import_module(LoopRef, Module) -> ok | {error, Reason}
704+
*/
705+
ERL_NIF_TERM nif_loop_import_module(ErlNifEnv *env, int argc,
706+
const ERL_NIF_TERM argv[]);
707+
708+
/**
709+
* @brief Import a module and cache a specific function
710+
*
711+
* Pre-imports the module and caches the function reference.
712+
* The __main__ module is never cached (returns error).
713+
*
714+
* NIF: loop_import_function(LoopRef, Module, Func) -> ok | {error, Reason}
715+
*/
716+
ERL_NIF_TERM nif_loop_import_function(ErlNifEnv *env, int argc,
717+
const ERL_NIF_TERM argv[]);
718+
719+
/**
720+
* @brief Flush the import cache for an event loop's interpreter
721+
*
722+
* Clears the module/function cache for all namespaces in this loop.
723+
*
724+
* NIF: loop_flush_import_cache(LoopRef) -> ok
725+
*/
726+
ERL_NIF_TERM nif_loop_flush_import_cache(ErlNifEnv *env, int argc,
727+
const ERL_NIF_TERM argv[]);
728+
729+
/**
730+
* @brief Get import cache statistics for the calling process's namespace
731+
*
732+
* Returns a map with count of cached entries.
733+
*
734+
* NIF: loop_import_stats(LoopRef) -> {ok, #{count => N}} | {error, Reason}
735+
*/
736+
ERL_NIF_TERM nif_loop_import_stats(ErlNifEnv *env, int argc,
737+
const ERL_NIF_TERM argv[]);
738+
739+
/**
740+
* @brief List all cached imports in the calling process's namespace
741+
*
742+
* Returns a list of binary strings with cached module and function names.
743+
*
744+
* NIF: loop_import_list(LoopRef) -> {ok, [binary()]} | {error, Reason}
745+
*/
746+
ERL_NIF_TERM nif_loop_import_list(ErlNifEnv *env, int argc,
747+
const ERL_NIF_TERM argv[]);
748+
693749
/* ============================================================================
694750
* Internal Helper Functions
695751
* ============================================================================ */

c_src/py_nif.c

Lines changed: 59 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2475,27 +2475,42 @@ static void owngil_execute_call(py_context_t *ctx) {
24752475
return;
24762476
}
24772477

2478-
/* Get or import module */
2479-
PyObject *module = context_get_module(ctx, module_name);
2480-
if (module == NULL) {
2481-
ctx->response_term = make_py_error(ctx->shared_env);
2482-
ctx->response_ok = false;
2483-
enif_free(module_name);
2484-
enif_free(func_name_str);
2485-
return;
2486-
}
2478+
PyObject *module = NULL;
2479+
PyObject *func = NULL;
24872480

2488-
/* Get function */
2489-
PyObject *func = PyObject_GetAttrString(module, func_name_str);
2490-
enif_free(module_name);
2491-
enif_free(func_name_str);
2481+
/* Special handling for __main__ module - check ctx->globals first */
2482+
if (strcmp(module_name, "__main__") == 0) {
2483+
func = PyDict_GetItemString(ctx->globals, func_name_str); /* Borrowed ref */
2484+
if (func != NULL) {
2485+
Py_INCREF(func);
2486+
}
2487+
}
24922488

24932489
if (func == NULL) {
2494-
ctx->response_term = make_py_error(ctx->shared_env);
2495-
ctx->response_ok = false;
2496-
return;
2490+
/* Get or import module */
2491+
module = context_get_module(ctx, module_name);
2492+
if (module == NULL) {
2493+
ctx->response_term = make_py_error(ctx->shared_env);
2494+
ctx->response_ok = false;
2495+
enif_free(module_name);
2496+
enif_free(func_name_str);
2497+
return;
2498+
}
2499+
2500+
/* Get function */
2501+
func = PyObject_GetAttrString(module, func_name_str);
2502+
if (func == NULL) {
2503+
ctx->response_term = make_py_error(ctx->shared_env);
2504+
ctx->response_ok = false;
2505+
enif_free(module_name);
2506+
enif_free(func_name_str);
2507+
return;
2508+
}
24972509
}
24982510

2511+
enif_free(module_name);
2512+
enif_free(func_name_str);
2513+
24992514
/* Convert args */
25002515
unsigned int args_len;
25012516
if (!enif_get_list_length(ctx->shared_env, args_term, &args_len)) {
@@ -4251,18 +4266,31 @@ static ERL_NIF_TERM nif_context_call(ErlNifEnv *env, int argc, const ERL_NIF_TER
42514266
bool prev_allow_suspension = tl_allow_suspension;
42524267
tl_allow_suspension = true;
42534268

4254-
/* Get or import module */
4255-
PyObject *module = context_get_module(ctx, module_name);
4256-
if (module == NULL) {
4257-
result = make_py_error(env);
4258-
goto cleanup;
4269+
PyObject *module = NULL;
4270+
PyObject *func = NULL;
4271+
4272+
/* Special handling for __main__ module - check ctx->globals first */
4273+
if (strcmp(module_name, "__main__") == 0) {
4274+
func = PyDict_GetItemString(ctx->globals, func_name); /* Borrowed ref */
4275+
if (func != NULL) {
4276+
Py_INCREF(func);
4277+
}
42594278
}
42604279

4261-
/* Get function */
4262-
PyObject *func = PyObject_GetAttrString(module, func_name);
42634280
if (func == NULL) {
4264-
result = make_py_error(env);
4265-
goto cleanup;
4281+
/* Get or import module */
4282+
module = context_get_module(ctx, module_name);
4283+
if (module == NULL) {
4284+
result = make_py_error(env);
4285+
goto cleanup;
4286+
}
4287+
4288+
/* Get function */
4289+
func = PyObject_GetAttrString(module, func_name);
4290+
if (func == NULL) {
4291+
result = make_py_error(env);
4292+
goto cleanup;
4293+
}
42664294
}
42674295

42684296
/* Convert args */
@@ -6764,6 +6792,12 @@ static ErlNifFunc nif_funcs[] = {
67646792
/* Per-process namespace NIFs */
67656793
{"event_loop_exec", 2, nif_event_loop_exec, ERL_NIF_DIRTY_JOB_IO_BOUND},
67666794
{"event_loop_eval", 2, nif_event_loop_eval, ERL_NIF_DIRTY_JOB_IO_BOUND},
6795+
/* Module import caching NIFs */
6796+
{"loop_import_module", 2, nif_loop_import_module, ERL_NIF_DIRTY_JOB_IO_BOUND},
6797+
{"loop_import_function", 3, nif_loop_import_function, ERL_NIF_DIRTY_JOB_IO_BOUND},
6798+
{"loop_flush_import_cache", 1, nif_loop_flush_import_cache, 0},
6799+
{"loop_import_stats", 1, nif_loop_import_stats, 0},
6800+
{"loop_import_list", 1, nif_loop_import_list, 0},
67676801
{"add_reader", 3, nif_add_reader, 0},
67686802
{"remove_reader", 2, nif_remove_reader, 0},
67696803
{"add_writer", 3, nif_add_writer, 0},

src/py.erl

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@
5656
stream/4,
5757
stream_eval/1,
5858
stream_eval/2,
59+
%% Module import caching
60+
import/1,
61+
import/2,
62+
flush_imports/0,
63+
import_stats/0,
64+
import_list/0,
5965
version/0,
6066
memory_stats/0,
6167
gc/0,
@@ -327,6 +333,93 @@ exec(Ctx, Code) when is_pid(Ctx) ->
327333
EnvRef = get_local_env(Ctx),
328334
py_context:exec(Ctx, Code, EnvRef).
329335

336+
%%% ============================================================================
337+
%%% Module Import Caching
338+
%%% ============================================================================
339+
340+
%% @doc Import and cache a module in the current interpreter.
341+
%%
342+
%% The module is imported in the interpreter handling this process (via affinity).
343+
%% The `__main__' module is never cached in the interpreter cache.
344+
%%
345+
%% This is useful for pre-warming imports before making calls, ensuring the
346+
%% first call doesn't pay the import penalty.
347+
%%
348+
%% Example:
349+
%% ```
350+
%% ok = py:import(json),
351+
%% {ok, Result} = py:call(json, dumps, [Data]). %% Uses cached module
352+
%% '''
353+
%%
354+
%% @param Module Python module name
355+
%% @returns ok | {error, Reason}
356+
-spec import(py_module()) -> ok | {error, term()}.
357+
import(Module) ->
358+
py_event_loop_pool:import(Module).
359+
360+
%% @doc Import and cache a module function in the current interpreter.
361+
%%
362+
%% Pre-imports the module and caches the function reference for faster
363+
%% subsequent calls. The `__main__' module is never cached.
364+
%%
365+
%% Example:
366+
%% ```
367+
%% ok = py:import(json, dumps),
368+
%% {ok, Result} = py:call(json, dumps, [Data]). %% Uses cached function
369+
%% '''
370+
%%
371+
%% @param Module Python module name
372+
%% @param Func Function name to cache
373+
%% @returns ok | {error, Reason}
374+
-spec import(py_module(), py_func()) -> ok | {error, term()}.
375+
import(Module, Func) ->
376+
py_event_loop_pool:import(Module, Func).
377+
378+
%% @doc Flush import caches across all interpreters.
379+
%%
380+
%% Clears the module/function cache in all interpreters. Use this after
381+
%% modifying Python modules on disk to force re-import.
382+
%%
383+
%% @returns ok
384+
-spec flush_imports() -> ok.
385+
flush_imports() ->
386+
py_event_loop_pool:flush_imports().
387+
388+
%% @doc Get import cache statistics for the current interpreter.
389+
%%
390+
%% Returns a map with cache metrics for the interpreter handling this process.
391+
%%
392+
%% Example:
393+
%% ```
394+
%% {ok, #{count => 5}} = py:import_stats().
395+
%% '''
396+
%%
397+
%% @returns {ok, Stats} where Stats is a map with cache metrics
398+
-spec import_stats() -> {ok, map()} | {error, term()}.
399+
import_stats() ->
400+
py_event_loop_pool:import_stats().
401+
402+
%% @doc List all cached imports in the current interpreter.
403+
%%
404+
%% Returns a map of modules to their cached functions.
405+
%% Module names are binary keys, function lists are the values.
406+
%% An empty list means only the module is cached (no specific functions).
407+
%%
408+
%% Example:
409+
%% ```
410+
%% ok = py:import(json),
411+
%% ok = py:import(json, dumps),
412+
%% ok = py:import(json, loads),
413+
%% ok = py:import(math),
414+
%% {ok, #{<<"json">> => [<<"dumps">>, <<"loads">>],
415+
%% <<"math">> => []}} = py:import_list().
416+
%% '''
417+
%%
418+
%% @returns {ok, #{Module => [Func]}} map of modules to functions
419+
-spec import_list() -> {ok, #{binary() => [binary()]}} | {error, term()}.
420+
import_list() ->
421+
py_event_loop_pool:import_list().
422+
330423
%%% ============================================================================
331424
%%% Asynchronous API
332425
%%% ============================================================================

src/py_context.erl

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,14 @@
3535

3636
-export([
3737
start_link/2,
38+
new/1,
3839
stop/1,
40+
destroy/1,
41+
call/4,
3942
call/5,
4043
call/6,
4144
call/7,
45+
eval/2,
4246
eval/3,
4347
eval/4,
4448
eval/5,
@@ -121,6 +125,38 @@ stop(Ctx) when is_pid(Ctx) ->
121125
ok
122126
end.
123127

128+
%% @doc Create a new context with options map.
129+
%%
130+
%% Options:
131+
%% - `mode' - Context mode (auto | subinterp | worker | owngil), default: auto
132+
%%
133+
%% @param Opts Options map
134+
%% @returns {ok, Pid} | {error, Reason}
135+
-spec new(map()) -> {ok, context()} | {error, term()}.
136+
new(Opts) when is_map(Opts) ->
137+
Mode = maps:get(mode, Opts, auto),
138+
Id = erlang:unique_integer([positive]),
139+
start_link(Id, Mode).
140+
141+
%% @doc Alias for stop/1 for API consistency.
142+
-spec destroy(context()) -> ok.
143+
destroy(Ctx) ->
144+
stop(Ctx).
145+
146+
%% @doc Call a Python function with empty kwargs.
147+
%%
148+
%% This is a convenience wrapper for call/5 that defaults Kwargs to #{}.
149+
%%
150+
%% @param Ctx Context process
151+
%% @param Module Python module name
152+
%% @param Func Function name
153+
%% @param Args List of arguments
154+
%% @returns {ok, Result} | {error, Reason}
155+
-spec call(context(), atom() | binary(), atom() | binary(), list()) ->
156+
{ok, term()} | {error, term()}.
157+
call(Ctx, Module, Func, Args) ->
158+
call(Ctx, Module, Func, Args, #{}).
159+
124160
%% @doc Call a Python function.
125161
%%
126162
%% @param Ctx Context process
@@ -181,6 +217,18 @@ call(Ctx, Module, Func, Args, Kwargs, Timeout, EnvRef) when is_pid(Ctx), is_refe
181217
{error, timeout}
182218
end.
183219

220+
%% @doc Evaluate a Python expression with empty locals.
221+
%%
222+
%% This is a convenience wrapper for eval/3 that defaults Locals to #{}.
223+
%%
224+
%% @param Ctx Context process
225+
%% @param Code Python code to evaluate
226+
%% @returns {ok, Result} | {error, Reason}
227+
-spec eval(context(), binary() | string()) ->
228+
{ok, term()} | {error, term()}.
229+
eval(Ctx, Code) ->
230+
eval(Ctx, Code, #{}).
231+
184232
%% @doc Evaluate a Python expression.
185233
%%
186234
%% @param Ctx Context process

0 commit comments

Comments
 (0)