-
-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathpy_import_SUITE.erl
More file actions
847 lines (677 loc) · 29.5 KB
/
py_import_SUITE.erl
File metadata and controls
847 lines (677 loc) · 29.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
%%% @doc Test suite for py_import:ensure_imported/1,2
-module(py_import_SUITE).
-include_lib("common_test/include/ct.hrl").
-include_lib("stdlib/include/assert.hrl").
-export([
all/0,
groups/0,
init_per_suite/1,
end_per_suite/1,
init_per_group/2,
end_per_group/2,
init_per_testcase/2,
end_per_testcase/2
]).
-export([
import_module_test/1,
import_function_test/1,
import_main_rejected_test/1,
import_nonexistent_module_test/1,
import_nonexistent_function_test/1,
import_idempotent_test/1,
import_list_test/1,
import_speeds_up_calls_test/1,
import_multiprocess_test/1,
import_concurrent_stress_test/1,
%% Import registry tests
import_registry_test/1,
import_applied_to_new_context_test/1,
clear_imports_test/1,
get_imports_test/1,
%% Per-interpreter sharing tests
shared_interpreter_import_test/1,
event_loop_pool_import_test/1,
spawn_task_uses_import_test/1,
subinterp_isolation_test/1,
registry_applied_to_subinterp_test/1,
%% sys.modules verification tests
import_in_sys_modules_test/1,
registry_import_in_sys_modules_test/1,
context_import_in_sys_modules_test/1,
%% Path registry tests
add_path_test/1,
%% Immediate application tests
import_applies_to_running_interpreter_test/1,
path_applies_to_running_interpreter_test/1
]).
all() ->
[{group, import_tests}].
groups() ->
[{import_tests, [sequence], [
import_module_test,
import_function_test,
import_main_rejected_test,
import_nonexistent_module_test,
import_nonexistent_function_test,
import_idempotent_test,
import_list_test,
import_speeds_up_calls_test,
import_multiprocess_test,
import_concurrent_stress_test,
%% Import registry tests
import_registry_test,
import_applied_to_new_context_test,
clear_imports_test,
get_imports_test,
%% Per-interpreter sharing tests
shared_interpreter_import_test,
event_loop_pool_import_test,
spawn_task_uses_import_test,
subinterp_isolation_test,
registry_applied_to_subinterp_test,
%% sys.modules verification tests
import_in_sys_modules_test,
registry_import_in_sys_modules_test,
context_import_in_sys_modules_test,
%% Path registry tests
add_path_test,
%% Immediate application tests
import_applies_to_running_interpreter_test,
path_applies_to_running_interpreter_test
]}].
init_per_suite(Config) ->
application:ensure_all_started(erlang_python),
timer:sleep(500),
Config.
end_per_suite(_Config) ->
%% Clean up imports to avoid affecting subsequent test suites
py_import:clear_imports(),
ok.
init_per_group(_Group, Config) ->
Config.
end_per_group(_Group, _Config) ->
ok.
init_per_testcase(_TestCase, Config) ->
%% Flush imports before each test for clean state
py_import:clear_imports(),
Config.
end_per_testcase(_TestCase, _Config) ->
ok.
%% @doc Test importing a module
import_module_test(_Config) ->
%% Import json module
ok = py_import:ensure_imported(json),
%% Verify it works by calling a function
{ok, Result} = py:call(json, dumps, [[1, 2, 3]]),
?assertEqual(<<"[1, 2, 3]">>, Result),
%% Import with binary name
ok = py_import:ensure_imported(<<"math">>),
{ok, Pi} = py:call(math, sqrt, [4.0]),
?assertEqual(2.0, Pi).
%% @doc Test importing a specific function
import_function_test(_Config) ->
%% Import json.dumps
ok = py_import:ensure_imported(json, dumps),
%% Verify it works
{ok, Result} = py:call(json, dumps, [#{a => 1}]),
?assert(is_binary(Result)),
%% Import with binary names
ok = py_import:ensure_imported(<<"os">>, <<"getcwd">>),
{ok, Cwd} = py:call(os, getcwd, []),
?assert(is_binary(Cwd)).
%% @doc Test that __main__ cannot be imported
import_main_rejected_test(_Config) ->
%% __main__ should be rejected
{error, main_not_cacheable} = py_import:ensure_imported('__main__'),
{error, main_not_cacheable} = py_import:ensure_imported(<<"__main__">>),
%% Also for function import
{error, main_not_cacheable} = py_import:ensure_imported('__main__', some_func),
{error, main_not_cacheable} = py_import:ensure_imported(<<"__main__">>, <<"some_func">>).
%% @doc Test importing nonexistent module - registry accepts it but call fails
%%
%% py:import just adds to the registry. The actual import error happens
%% when trying to use the module.
import_nonexistent_module_test(_Config) ->
%% Import succeeds (just adds to registry)
ok = py_import:ensure_imported(nonexistent_module_xyz),
%% But trying to use it fails
%% Error format is {error, {ExceptionType, Message}} or {error, atom()}
{error, Reason} = py:call(nonexistent_module_xyz, some_func, []),
?assert(is_tuple(Reason) orelse is_list(Reason) orelse is_binary(Reason) orelse is_atom(Reason)),
ct:pal("Nonexistent module error at call time: ~p", [Reason]).
%% @doc Test importing with nonexistent function name still imports the module
%%
%% py:import/2 imports the module into sys.modules. The function name is
%% stored in the registry but not validated at import time. Function
%% validation happens at call time.
import_nonexistent_function_test(_Config) ->
%% Module exists but function doesn't - import still succeeds
%% because we're importing the MODULE, not the function
ok = py_import:ensure_imported(json, nonexistent_function_xyz),
%% The module is imported and usable
{ok, _} = py:call(json, dumps, [[1, 2, 3]]),
%% But calling the nonexistent function will fail
{error, _Reason} = py:call(json, nonexistent_function_xyz, []),
ct:pal("Import with invalid function succeeds (validation at call time)").
%% @doc Test that importing same module/function twice is idempotent
import_idempotent_test(_Config) ->
%% Import multiple times - should all succeed
ok = py_import:ensure_imported(json),
ok = py_import:ensure_imported(json),
ok = py_import:ensure_imported(json),
ok = py_import:ensure_imported(json, dumps),
ok = py_import:ensure_imported(json, dumps),
ok = py_import:ensure_imported(json, dumps),
%% Still works
{ok, _} = py:call(json, dumps, [[1]]).
%% @doc Test listing imports
import_list_test(_Config) ->
%% Start fresh
ok = py_import:clear_imports(),
%% Empty map
{ok, Map0} = py_import:import_list(),
?assertEqual(#{}, Map0),
%% Import some modules and functions
ok = py_import:ensure_imported(json),
ok = py_import:ensure_imported(math),
ok = py_import:ensure_imported(json, dumps),
ok = py_import:ensure_imported(json, loads),
%% Get map
{ok, Map1} = py_import:import_list(),
%% Check structure: should have json and math as keys
?assert(maps:is_key(<<"json">>, Map1)),
?assert(maps:is_key(<<"math">>, Map1)),
%% json should have dumps and loads as functions
JsonFuncs = maps:get(<<"json">>, Map1),
?assertEqual(2, length(JsonFuncs)),
?assert(lists:member(<<"dumps">>, JsonFuncs)),
?assert(lists:member(<<"loads">>, JsonFuncs)),
%% math should have empty function list (only module cached)
MathFuncs = maps:get(<<"math">>, Map1),
?assertEqual([], MathFuncs),
ct:pal("Import list: ~p", [Map1]).
%% @doc Test that pre-importing speeds up subsequent calls
import_speeds_up_calls_test(_Config) ->
%% Flush to ensure cold start
ok = py_import:clear_imports(),
%% Time a cold call (module not imported)
%% Using json.dumps since hashlib.md5 needs bytes encoding
{ColdTime, {ok, _}} = timer:tc(fun() ->
py:call(json, dumps, [[1,2,3,4,5]])
end),
%% Pre-import the module and function
ok = py_import:ensure_imported(json),
ok = py_import:ensure_imported(json, dumps),
%% Time a warm call (module already imported)
{WarmTime, {ok, _}} = timer:tc(fun() ->
py:call(json, dumps, [[1,2,3,4,5]])
end),
ct:pal("Cold call time: ~p us, Warm call time: ~p us", [ColdTime, WarmTime]),
%% Warm call should generally be faster, but we don't assert
%% because timing can be variable. Just log for observation.
ok.
%% @doc Test that multiple processes can use the shared import registry
%%
%% The import registry is global (ETS table) and sys.modules is per-interpreter.
%% All processes using the same interpreter share the same cached modules.
import_multiprocess_test(_Config) ->
Parent = self(),
%% Clear registry first
ok = py_import:clear_imports(),
%% Spawn 3 processes, each importing different modules
%% They all contribute to the same global registry
Pid1 = spawn_link(fun() ->
ok = py_import:ensure_imported(json),
ok = py_import:ensure_imported(json, dumps),
%% Verify we can use the import
{ok, _} = py:call(json, dumps, [[1,2,3]]),
Parent ! {self(), done}
end),
Pid2 = spawn_link(fun() ->
ok = py_import:ensure_imported(math),
ok = py_import:ensure_imported(math, sqrt),
ok = py_import:ensure_imported(math, floor),
%% Verify we can use the import
{ok, _} = py:call(math, sqrt, [16.0]),
Parent ! {self(), done}
end),
Pid3 = spawn_link(fun() ->
ok = py_import:ensure_imported(os),
ok = py_import:ensure_imported(os, getcwd),
ok = py_import:ensure_imported(string),
%% Verify we can use the import
{ok, _} = py:call(os, getcwd, []),
Parent ! {self(), done}
end),
%% Collect results from all 3 processes
Results = [receive {Pid, Result} -> Result after 5000 -> timeout end
|| Pid <- [Pid1, Pid2, Pid3]],
%% Verify no timeouts
?assertEqual(false, lists:member(timeout, Results)),
%% All processes completed successfully
?assertEqual([done, done, done], Results),
%% Now verify the GLOBAL registry has all entries
{ok, List} = py_import:import_list(),
%% Total entries: json, json.dumps, math, math.sqrt, math.floor, os, os.getcwd, string = 8
?assertEqual(8, length(py_import:all_imports())),
%% Verify all modules are in the shared registry
?assert(maps:is_key(<<"json">>, List)),
?assert(maps:is_key(<<"math">>, List)),
?assert(maps:is_key(<<"os">>, List)),
?assert(maps:is_key(<<"string">>, List)),
%% Verify function entries
?assert(lists:member(<<"dumps">>, maps:get(<<"json">>, List))),
MathFuncs = maps:get(<<"math">>, List),
?assert(lists:member(<<"sqrt">>, MathFuncs)),
?assert(lists:member(<<"floor">>, MathFuncs)),
?assert(lists:member(<<"getcwd">>, maps:get(<<"os">>, List))),
ct:pal("Global registry after multiprocess imports: ~p", [List]).
%% @doc Stress test with many concurrent processes importing simultaneously
%%
%% All processes contribute to the shared global registry.
import_concurrent_stress_test(_Config) ->
Parent = self(),
NumProcesses = 20,
Modules = [json, math, os, string, re, base64, collections, functools, itertools, operator],
%% Clear registry first
ok = py_import:clear_imports(),
%% Spawn many processes that all try to import at the same time
Pids = [spawn_link(fun() ->
%% Each process imports a random subset of modules
MyModules = lists:sublist(Modules, 1 + (N rem length(Modules))),
Results = [{M, py_import:ensure_imported(M)} || M <- MyModules],
%% All imports should succeed
AllOk = lists:all(fun({_, R}) -> R =:= ok end, Results),
%% Make a call to verify imports work
CallResult = py:call(json, dumps, [[N]]),
Parent ! {self(), {AllOk, CallResult}}
end) || N <- lists:seq(1, NumProcesses)],
%% Collect all results
Results = [receive {Pid, Result} -> Result after 10000 -> timeout end || Pid <- Pids],
%% Verify no timeouts
?assertEqual(false, lists:member(timeout, Results)),
%% Verify all processes succeeded
lists:foreach(fun({AllOk, CallResult}) ->
?assertEqual(true, AllOk),
?assertMatch({ok, _}, CallResult)
end, Results),
%% Verify the global registry has all modules
Count = length(py_import:all_imports()),
%% Should have all 10 modules (some may have been imported multiple times but ETS dedupes)
?assertEqual(10, Count),
ct:pal("All ~p processes completed successfully, ~p modules in registry", [NumProcesses, Count]).
%% ============================================================================
%% Import Registry Tests
%% ============================================================================
%% @doc Test that imports are added to the global registry
import_registry_test(_Config) ->
%% Clear any existing registry entries
ok = py_import:clear_imports(),
%% Verify registry is empty
[] = py_import:all_imports(),
%% Import a module
ok = py_import:ensure_imported(json),
%% Verify it's in the registry
Imports1 = py_import:all_imports(),
?assert(lists:member({<<"json">>, all}, Imports1)),
%% Import a function
ok = py_import:ensure_imported(math, sqrt),
%% Verify both are in the registry
Imports2 = py_import:all_imports(),
?assert(lists:member({<<"json">>, all}, Imports2)),
?assert(lists:member({<<"math">>, <<"sqrt">>}, Imports2)),
ct:pal("Registry contents: ~p", [Imports2]).
%% @doc Test that imports are automatically applied to new contexts
import_applied_to_new_context_test(_Config) ->
%% Clear and add an import
ok = py_import:clear_imports(),
ok = py_import:ensure_imported(json),
%% Create a new context
{ok, Ctx} = py_context:new(#{mode => worker}),
%% The json module should already be cached in the new context
%% We can verify by calling a function from it
{ok, Result} = py_context:call(Ctx, json, dumps, [[1, 2, 3]], #{}),
?assertEqual(<<"[1, 2, 3]">>, Result),
%% Clean up
py_context:destroy(Ctx),
ok = py_import:clear_imports().
%% @doc Test clearing all imports from the registry
clear_imports_test(_Config) ->
%% Add some imports
ok = py_import:ensure_imported(json),
ok = py_import:ensure_imported(math),
ok = py_import:ensure_imported(os),
%% Verify they're in the registry
Imports1 = py_import:all_imports(),
?assert(length(Imports1) >= 3),
%% Clear all
ok = py_import:clear_imports(),
%% Verify registry is empty
Imports2 = py_import:all_imports(),
?assertEqual([], Imports2).
%% @doc Test get_imports returns the correct format
get_imports_test(_Config) ->
%% Clear and add imports
ok = py_import:clear_imports(),
ok = py_import:ensure_imported(json),
ok = py_import:ensure_imported(math, sqrt),
%% Get imports
Imports = py_import:all_imports(),
%% Verify format
?assert(is_list(Imports)),
%% Check the entries
{_, JsonSpec} = lists:keyfind(<<"json">>, 1, Imports),
?assertEqual(all, JsonSpec),
{_, MathSpec} = lists:keyfind(<<"math">>, 1, Imports),
?assertEqual(<<"sqrt">>, MathSpec),
ct:pal("get_imports result: ~p", [Imports]).
%% ============================================================================
%% Per-Interpreter Sharing Tests
%% ============================================================================
%% @doc Test that two contexts sharing the same interpreter see imported modules
%%
%% When we import a module via one context, other contexts using the same
%% interpreter (same subinterpreter pool slot or main interpreter) should
%% see the module in sys.modules.
shared_interpreter_import_test(_Config) ->
%% Clear registry
ok = py_import:clear_imports(),
%% Create two worker-mode contexts (they share the main interpreter)
{ok, Ctx1} = py_context:new(#{mode => worker}),
{ok, Ctx2} = py_context:new(#{mode => worker}),
%% Import a module via Ctx1 by calling it (this adds to sys.modules)
{ok, _} = py_context:call(Ctx1, json, dumps, [[1, 2, 3]], #{}),
%% Now Ctx2 should be able to use json without re-importing
%% (it's already in sys.modules of the shared interpreter)
{ok, Result} = py_context:call(Ctx2, json, loads, [<<"[4, 5, 6]">>], #{}),
?assertEqual([4, 5, 6], Result),
%% Clean up
py_context:destroy(Ctx1),
py_context:destroy(Ctx2),
ct:pal("Worker contexts successfully shared interpreter's sys.modules").
%% @doc Test that event loop pool workers see imports from py:import
%%
%% When py:import is called, it imports into the current interpreter.
%% Event loop pool workers using the main interpreter should see these imports.
event_loop_pool_import_test(_Config) ->
%% Clear registry
ok = py_import:clear_imports(),
%% Import via py:import (goes to event loop pool's interpreter)
ok = py_import:ensure_imported(collections),
%% Verify we can use it via py:call (uses event loop pool)
{ok, Result} = py:call(collections, 'Counter', [[a, b, a, c, a, b]]),
?assert(is_map(Result) orelse is_tuple(Result)),
%% Import another module
ok = py_import:ensure_imported(itertools),
%% Use it
{ok, _} = py:call(itertools, chain, [[[1, 2], [3, 4]]]),
ct:pal("Event loop pool imports working correctly").
%% @doc Test that spawn_task uses imported modules
%%
%% When modules are imported via py:import, spawn_task should be able
%% to use them since they're in the interpreter's sys.modules.
spawn_task_uses_import_test(_Config) ->
%% Clear registry
ok = py_import:clear_imports(),
%% Import base64 module
ok = py_import:ensure_imported(base64),
%% Define a simple function that uses base64
Code = <<"
def encode_test(data):
import base64
return base64.b64encode(data.encode()).decode()
">>,
ok = py:exec(Code),
%% Use spawn_task to call our function
%% First verify direct call works
{ok, Encoded} = py:call('__main__', encode_test, [<<"hello">>]),
?assertEqual(<<"aGVsbG8=">>, Encoded),
%% Now test via spawn_task (fire and forget, but module should be available)
py_event_loop_pool:spawn_task(base64, b64encode, [<<"test">>]),
%% Give it time to execute
timer:sleep(100),
ct:pal("spawn_task can use imported modules").
%% @doc Test that different subinterpreters are isolated
%%
%% OWN_GIL contexts each have their own interpreter, so imports in one
%% should NOT be visible in another (different sys.modules).
subinterp_isolation_test(_Config) ->
%% Skip if OWN_GIL not supported (requires Python 3.14+)
case py_nif:owngil_supported() of
false ->
{skip, "OWN_GIL requires Python 3.14+"};
true ->
%% Clear registry so new contexts don't get pre-imported modules
ok = py_import:clear_imports(),
%% Create two OWN_GIL contexts (each has its own interpreter)
{ok, Ctx1} = py_context:new(#{mode => owngil}),
{ok, Ctx2} = py_context:new(#{mode => owngil}),
%% Define a variable in Ctx1's __main__
ok = py_context:exec(Ctx1, <<"test_var_isolation = 'ctx1_value'">>),
%% Try to access it from Ctx2 - should fail (different interpreter)
Result = py_context:eval(Ctx2, <<"test_var_isolation">>),
case Result of
{error, _} ->
%% Expected - variable not defined in Ctx2
ok;
{ok, <<"ctx1_value">>} ->
%% This would be wrong - isolation failed
ct:fail("Subinterpreter isolation failed - variable leaked between contexts")
end,
%% Clean up
py_context:destroy(Ctx1),
py_context:destroy(Ctx2),
ct:pal("Subinterpreter isolation verified - different interpreters are isolated")
end.
%% @doc Test that registry imports are applied to new subinterpreter contexts
%%
%% When py:import is called, it adds to the registry. New contexts should
%% have these imports applied to their interpreter.
registry_applied_to_subinterp_test(_Config) ->
%% Skip if subinterpreters not supported
case py_nif:subinterp_supported() of
false ->
{skip, "Subinterpreters not supported"};
true ->
%% Clear registry and add an import
ok = py_import:clear_imports(),
ok = py_import:ensure_imported(uuid),
%% Create a new subinterp context
{ok, Ctx} = py_context:new(#{mode => subinterp}),
%% The uuid module should be available (applied from registry)
{ok, Result} = py_context:call(Ctx, uuid, uuid4, [], #{}),
?assert(is_binary(Result) orelse is_list(Result)),
%% Clean up
py_context:destroy(Ctx),
ok = py_import:clear_imports(),
ct:pal("Registry imports successfully applied to new subinterpreter")
end.
%% ============================================================================
%% sys.modules Verification Tests
%% ============================================================================
%% @doc Test that py:import puts the module in sys.modules
%%
%% After calling py:import, the module should be in the interpreter's
%% sys.modules dictionary. We verify this by checking that calling
%% a function from the module works (which requires it to be imported).
%%
%% Note: We use textwrap (pure Python) instead of decimal because the
%% _decimal C extension has global state that crashes in subinterpreters.
import_in_sys_modules_test(_Config) ->
%% Clear registry
ok = py_import:clear_imports(),
%% Import a pure Python module (avoid C extensions like decimal
%% which have global state that crashes in subinterpreters)
ok = py_import:ensure_imported(textwrap),
%% Verify the import worked by calling a function
{ok, _} = py:call(textwrap, fill, [<<"Hello world">>, 5]),
%% Now check sys.modules using the same process (important!)
%% We use exec to define a helper, then eval to check
ok = py:exec(<<"
import sys
_test_textwrap_in_sys = 'textwrap' in sys.modules
">>),
{ok, InSysModules} = py:eval(<<"_test_textwrap_in_sys">>),
?assertEqual(true, InSysModules),
ct:pal("py_import:ensure_imported correctly adds module to sys.modules").
%% @doc Test that ETS registry and sys.modules stay in sync
%%
%% The ETS registry tracks what should be imported, and sys.modules
%% contains the actual imported modules.
%%
%% Note: Avoid modules that import C extensions with global state issues
%% (e.g., statistics imports _decimal). Use json which has proper
%% subinterpreter support. See https://github.com/python/cpython/issues/106078
registry_import_in_sys_modules_test(_Config) ->
%% Clear registry
ok = py_import:clear_imports(),
%% Add to registry and import
ok = py_import:ensure_imported(fractions),
ok = py_import:ensure_imported(json),
%% Verify ETS registry has the entries
Registry = py_import:all_imports(),
?assert(lists:member({<<"fractions">>, all}, Registry)),
?assert(lists:member({<<"json">>, all}, Registry)),
%% Use the modules to ensure they're imported
{ok, _} = py:call(fractions, 'Fraction', [1, 3]),
{ok, _} = py:call(json, dumps, [[1, 2, 3]]),
%% Verify both are in sys.modules by checking from Python
ok = py:exec(<<"
import sys
_fractions_in_sys = 'fractions' in sys.modules
_json_in_sys = 'json' in sys.modules
_sys_modules_keys = list(sys.modules.keys())
">>),
{ok, FractionsInSys} = py:eval(<<"_fractions_in_sys">>),
{ok, JsonInSys} = py:eval(<<"_json_in_sys">>),
?assertEqual(true, FractionsInSys),
?assertEqual(true, JsonInSys),
%% Get the list of modules in sys.modules that match our registry
{ok, SysModulesList} = py:eval(<<"_sys_modules_keys">>),
?assert(lists:member(<<"fractions">>, SysModulesList)),
?assert(lists:member(<<"json">>, SysModulesList)),
ct:pal("ETS registry and sys.modules are in sync").
%% @doc Test that context imports go to sys.modules of that interpreter
%%
%% When using py_context to import/call, the module should end up in
%% the interpreter's sys.modules.
context_import_in_sys_modules_test(_Config) ->
%% Clear registry
ok = py_import:clear_imports(),
%% Create a context
{ok, Ctx} = py_context:new(#{mode => worker}),
%% Call a function from a module (this imports it)
{ok, _} = py_context:call(Ctx, textwrap, fill, [<<"Hello world this is a test">>, 10], #{}),
%% Check if textwrap is in sys.modules of this interpreter
%% Use exec then eval pattern for reliable checking
ok = py_context:exec(Ctx, <<"
import sys
_textwrap_in_sys = 'textwrap' in sys.modules
_sys_keys = list(sys.modules.keys())
">>),
{ok, InSysModules} = py_context:eval(Ctx, <<"_textwrap_in_sys">>),
?assertEqual(true, InSysModules),
%% Get the sys.modules keys to see what's imported
{ok, SysKeys} = py_context:eval(Ctx, <<"_sys_keys">>),
?assert(lists:member(<<"textwrap">>, SysKeys)),
%% Clean up
py_context:destroy(Ctx),
ct:pal("Context imports correctly populate sys.modules").
%% ============================================================================
%% Path Registry Tests
%% ============================================================================
%% @doc Test that add_path registers a path and makes modules importable
%%
%% This test creates a custom module in priv_dir, adds its path via add_path,
%% then verifies the module can be imported and called.
add_path_test(Config) ->
%% Clear any existing paths
ok = py_import:clear_paths(),
%% Create test module in priv_dir (guaranteed writable during tests)
PrivDir = ?config(priv_dir, Config),
ModuleDir = filename:join(PrivDir, "custom_modules"),
ok = filelib:ensure_dir(filename:join(ModuleDir, "dummy")),
%% Write a simple Python module
ModulePath = filename:join(ModuleDir, "sample_module.py"),
ModuleContent = <<"def greet(name):\n"
" return f\"Hello, {name}!\"\n"
"\n"
"def add(a, b):\n"
" return a + b\n"
"\n"
"VERSION = \"1.0.0\"\n">>,
ok = file:write_file(ModulePath, ModuleContent),
%% Add path
ok = py_import:add_path(ModuleDir),
%% Verify path is registered
?assert(py_import:is_path_added(ModuleDir)),
Paths = py_import:all_paths(),
?assertEqual(1, length(Paths)),
%% Create a new context to apply paths
{ok, Ctx} = py_context:new(#{mode => worker}),
%% Import and call the sample module
{ok, Greeting} = py_context:call(Ctx, sample_module, greet, [<<"World">>], #{}),
?assertEqual(<<"Hello, World!">>, Greeting),
{ok, Sum} = py_context:call(Ctx, sample_module, add, [2, 3], #{}),
?assertEqual(5, Sum),
%% Clean up
py_context:destroy(Ctx),
ok = py_import:clear_paths(),
ct:pal("add_path successfully registers paths and enables module imports").
%% ============================================================================
%% Immediate Application Tests
%% ============================================================================
%% @doc Test that ensure_imported applies immediately to running interpreters
%%
%% This verifies that calling ensure_imported on an already-running interpreter
%% makes the module available without needing to create a new context.
import_applies_to_running_interpreter_test(_Config) ->
%% Clear registry
ok = py_import:clear_imports(),
%% Verify 'zipfile' is NOT in sys.modules yet
ok = py:exec(<<"import sys; _zipfile_before = 'zipfile' in sys.modules">>),
{ok, BeforeImport} = py:eval(<<"_zipfile_before">>),
?assertEqual(false, BeforeImport),
%% Now call ensure_imported - should apply immediately
ok = py_import:ensure_imported(zipfile),
%% Verify 'zipfile' IS now in sys.modules (without creating new context)
ok = py:exec(<<"import sys; _zipfile_after = 'zipfile' in sys.modules">>),
{ok, AfterImport} = py:eval(<<"_zipfile_after">>),
?assertEqual(true, AfterImport),
%% Verify we can call functions from zipfile
{ok, _} = py:call(zipfile, 'is_zipfile', [<<"/nonexistent">>]),
ct:pal("ensure_imported applies immediately to running interpreter").
%% @doc Test that add_path applies immediately to running interpreters
%%
%% This verifies that calling add_path on an already-running interpreter
%% makes the path available in sys.path without needing to create a new context.
path_applies_to_running_interpreter_test(Config) ->
%% Clear paths
ok = py_import:clear_paths(),
%% Create test module in priv_dir
PrivDir = ?config(priv_dir, Config),
ModuleDir = filename:join(PrivDir, "immediate_path_test"),
ok = filelib:ensure_dir(filename:join(ModuleDir, "dummy")),
%% Write a simple Python module
ModulePath = filename:join(ModuleDir, "immediate_test_mod.py"),
ModuleContent = <<"IMMEDIATE_TEST_VALUE = 42\n">>,
ok = file:write_file(ModulePath, ModuleContent),
ModuleDirBin = list_to_binary(ModuleDir),
%% Verify path is NOT in sys.path yet
CheckCode = <<"import sys; _path_before = '", ModuleDirBin/binary, "' in sys.path">>,
ok = py:exec(CheckCode),
{ok, BeforePath} = py:eval(<<"_path_before">>),
?assertEqual(false, BeforePath),
%% Now call add_path - should apply immediately
ok = py_import:add_path(ModuleDir),
%% Verify path IS now in sys.path (without creating new context)
CheckAfterCode = <<"import sys; _path_after = '", ModuleDirBin/binary, "' in sys.path">>,
ok = py:exec(CheckAfterCode),
{ok, AfterPath} = py:eval(<<"_path_after">>),
?assertEqual(true, AfterPath),
%% Verify we can import and use the module
{ok, Value} = py:eval(<<"__import__('immediate_test_mod').IMMEDIATE_TEST_VALUE">>),
?assertEqual(42, Value),
%% Clean up
ok = py_import:clear_paths(),
ct:pal("add_path applies immediately to running interpreter").