Current section

Files

Jump to
erlang_python priv tests test_erlang_api.py
Raw

priv/tests/test_erlang_api.py

# Copyright 2026 Benoit Chesneau
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Erlang-specific API tests.
These tests verify the Erlang-specific extensions and compatibility APIs:
- ErlangEventLoop
- ErlangEventLoopPolicy
- erlang.run()
- erlang.install()
- erlang.new_event_loop()
The unified 'erlang' module provides both:
- Callback support: erlang.call(), erlang.async_call(), dynamic function access
- Event loop API: erlang.run(), erlang.new_event_loop(), erlang.ErlangEventLoop
"""
import asyncio
import sys
import time
import unittest
import warnings
from . import _testbase as tb
def _get_erlang_event_loop():
"""Get ErlangEventLoop class from available sources."""
# Try unified erlang module first
try:
import erlang
if hasattr(erlang, 'ErlangEventLoop'):
return erlang.ErlangEventLoop
except ImportError:
pass
# Try _erlang_impl package
try:
from _erlang_impl import ErlangEventLoop
return ErlangEventLoop
except ImportError:
pass
# Add parent directory to path and try again
import os
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if parent_dir not in sys.path:
sys.path.insert(0, parent_dir)
from _erlang_impl import ErlangEventLoop
return ErlangEventLoop
def _get_erlang_module():
"""Get the erlang module with event loop API."""
import erlang
if hasattr(erlang, 'run'):
return erlang
# Extension not loaded - try to load _erlang_impl manually
import os
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if parent_dir not in sys.path:
sys.path.insert(0, parent_dir)
import _erlang_impl
# Manually extend
erlang.run = _erlang_impl.run
erlang.new_event_loop = _erlang_impl.new_event_loop
erlang.install = _erlang_impl.install
erlang.EventLoopPolicy = _erlang_impl.EventLoopPolicy
erlang.ErlangEventLoop = _erlang_impl.ErlangEventLoop
return erlang
class TestErlangEventLoopCreation(unittest.TestCase):
"""Tests for ErlangEventLoop creation and basic properties."""
def test_create_event_loop(self):
"""Test creating an ErlangEventLoop."""
ErlangEventLoop = _get_erlang_event_loop()
loop = ErlangEventLoop()
self.assertIsInstance(loop, asyncio.AbstractEventLoop)
self.assertFalse(loop.is_running())
self.assertFalse(loop.is_closed())
loop.close()
self.assertTrue(loop.is_closed())
def test_event_loop_implements_interface(self):
"""Test that ErlangEventLoop implements AbstractEventLoop interface."""
ErlangEventLoop = _get_erlang_event_loop()
loop = ErlangEventLoop()
try:
# Check required methods exist
methods = [
'run_forever',
'run_until_complete',
'stop',
'close',
'is_running',
'is_closed',
'call_soon',
'call_later',
'call_at',
'time',
'create_future',
'create_task',
'add_reader',
'remove_reader',
'add_writer',
'remove_writer',
'sock_recv',
'sock_sendall',
'sock_connect',
'sock_accept',
'create_server',
'create_connection',
'create_datagram_endpoint',
'getaddrinfo',
'getnameinfo',
'run_in_executor',
'set_exception_handler',
'get_exception_handler',
'get_debug',
'set_debug',
]
for method in methods:
self.assertTrue(
hasattr(loop, method),
f"ErlangEventLoop missing method: {method}"
)
self.assertTrue(
callable(getattr(loop, method)),
f"ErlangEventLoop.{method} is not callable"
)
finally:
loop.close()
def _get_event_loop_policy():
"""Get ErlangEventLoopPolicy class from available sources."""
# Try unified erlang module first
try:
import erlang
if hasattr(erlang, 'EventLoopPolicy'):
return erlang.EventLoopPolicy
except ImportError:
pass
# Try _erlang_impl package
try:
from _erlang_impl._policy import ErlangEventLoopPolicy
return ErlangEventLoopPolicy
except ImportError:
pass
# Add parent directory to path and try again
import os
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if parent_dir not in sys.path:
sys.path.insert(0, parent_dir)
from _erlang_impl._policy import ErlangEventLoopPolicy
return ErlangEventLoopPolicy
class TestErlangEventLoopPolicy(unittest.TestCase):
"""Tests for ErlangEventLoopPolicy."""
def test_policy_creates_erlang_loop(self):
"""Test that policy creates ErlangEventLoop instances."""
ErlangEventLoop = _get_erlang_event_loop()
ErlangEventLoopPolicy = _get_event_loop_policy()
policy = ErlangEventLoopPolicy()
loop = policy.new_event_loop()
try:
self.assertIsInstance(loop, ErlangEventLoop)
finally:
loop.close()
def test_policy_get_event_loop(self):
"""Test policy.get_event_loop() method."""
ErlangEventLoopPolicy = _get_event_loop_policy()
policy = ErlangEventLoopPolicy()
old_policy = asyncio.get_event_loop_policy()
try:
asyncio.set_event_loop_policy(policy)
loop = policy.new_event_loop()
policy.set_event_loop(loop)
# get_event_loop should return the set loop
retrieved = policy.get_event_loop()
self.assertIs(retrieved, loop)
loop.close()
finally:
asyncio.set_event_loop_policy(old_policy)
class TestErlangModuleFunctions(unittest.TestCase):
"""Tests for erlang module functions."""
def test_new_event_loop(self):
"""Test erlang.new_event_loop() function."""
erlang = _get_erlang_module()
ErlangEventLoop = _get_erlang_event_loop()
loop = erlang.new_event_loop()
try:
self.assertIsInstance(loop, ErlangEventLoop)
finally:
loop.close()
def test_run_function(self):
"""Test erlang.run() function."""
erlang = _get_erlang_module()
async def main():
return 42
result = erlang.run(main())
self.assertEqual(result, 42)
def test_run_with_debug(self):
"""Test erlang.run() with debug flag."""
erlang = _get_erlang_module()
async def main():
return 'debug_test'
result = erlang.run(main(), debug=True)
self.assertEqual(result, 'debug_test')
def test_install_function(self):
"""Test erlang.install() function across supported Python versions."""
erlang = _get_erlang_module()
if sys.version_info >= (3, 14):
# 3.14 deprecated set_event_loop_policy and 3.16 removes it,
# so erlang.install() now raises with a migration message.
with self.assertRaises(RuntimeError) as cm:
erlang.install()
msg = str(cm.exception)
self.assertIn("3.14+", msg)
return
# 3.9-3.13: install() still works (DeprecationWarning on 3.12+).
# Suppress the asyncio DeprecationWarning emitted by the
# get_event_loop_policy() probe itself on those versions.
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
old_policy = asyncio.get_event_loop_policy()
try:
if sys.version_info >= (3, 12):
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
erlang.install()
self.assertTrue(
any(issubclass(warning.category, DeprecationWarning)
for warning in w)
)
# silent=True must suppress the warning even with
# simplefilter("always").
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
erlang.install(silent=True)
install_warnings = [
warning for warning in w
if "erlang.install()" in str(warning.message)
]
self.assertEqual(install_warnings, [])
else:
erlang.install()
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
policy = asyncio.get_event_loop_policy()
self.assertIsInstance(policy, erlang.EventLoopPolicy)
finally:
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
asyncio.set_event_loop_policy(old_policy)
class TestErlangLoopSpecificFeatures(tb.ErlangTestCase):
"""Tests for Erlang-specific event loop features."""
def test_time_resolution(self):
"""Test time resolution."""
t1 = self.loop.time()
t2 = self.loop.time()
# Times should be monotonic
self.assertGreaterEqual(t2, t1)
def test_shutdown_asyncgens(self):
"""Test shutdown_asyncgens method."""
async def main():
await self.loop.shutdown_asyncgens()
self.loop.run_until_complete(main())
def test_shutdown_default_executor(self):
"""Test shutdown_default_executor method."""
# First use the executor
def blocking():
return 42
async def main():
result = await self.loop.run_in_executor(None, blocking)
await self.loop.shutdown_default_executor()
return result
result = self.loop.run_until_complete(main())
self.assertEqual(result, 42)
class TestUVLoopCompatibility(tb.ErlangTestCase):
"""Tests for uvloop API compatibility."""
def test_uvloop_like_api(self):
"""Test that erlang module provides uvloop-like API."""
erlang = _get_erlang_module()
# uvloop provides these
self.assertTrue(hasattr(erlang, 'run'))
self.assertTrue(hasattr(erlang, 'new_event_loop'))
self.assertTrue(hasattr(erlang, 'install'))
self.assertTrue(hasattr(erlang, 'EventLoopPolicy'))
def test_event_loop_is_asyncio_compatible(self):
"""Test that ErlangEventLoop works with asyncio functions."""
async def main():
# Test asyncio.sleep
await asyncio.sleep(0.01)
# Test asyncio.gather
results = await asyncio.gather(
asyncio.sleep(0.01, result=1),
asyncio.sleep(0.01, result=2),
)
return results
results = self.loop.run_until_complete(main())
self.assertEqual(results, [1, 2])
def test_drop_in_replacement(self):
"""Test that ErlangEventLoop can be used as drop-in replacement."""
# Create a function that uses asyncio APIs
async def typical_async_code():
# Task creation
task = asyncio.create_task(asyncio.sleep(0.01, result='task'))
# Future
future = self.loop.create_future()
self.loop.call_soon(future.set_result, 'future')
# Gather results
task_result = await task
future_result = await future
return task_result, future_result
results = self.loop.run_until_complete(typical_async_code())
self.assertEqual(results, ('task', 'future'))
def _get_mode_module():
"""Get the mode module for execution mode detection."""
# Try unified erlang module first
try:
import erlang
if hasattr(erlang, 'detect_mode') and hasattr(erlang, 'ExecutionMode'):
return erlang
except ImportError:
pass
# Try _erlang_impl package
try:
from _erlang_impl._mode import detect_mode, ExecutionMode
class _ModeModule:
detect_mode = detect_mode
ExecutionMode = ExecutionMode
return _ModeModule()
except ImportError:
pass
# Add parent directory to path and try again
import os
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if parent_dir not in sys.path:
sys.path.insert(0, parent_dir)
from _erlang_impl._mode import detect_mode, ExecutionMode
class _ModeModule:
detect_mode = detect_mode
ExecutionMode = ExecutionMode
return _ModeModule()
class TestExecutionMode(unittest.TestCase):
"""Tests for execution mode detection."""
def test_detect_mode(self):
"""Test execution mode detection."""
mode_module = _get_mode_module()
mode = mode_module.detect_mode()
self.assertIsInstance(mode, mode_module.ExecutionMode)
def test_execution_modes_defined(self):
"""Test that execution modes are defined."""
mode_module = _get_mode_module()
ExecutionMode = mode_module.ExecutionMode
# Check that expected modes exist
self.assertTrue(hasattr(ExecutionMode, 'SHARED_GIL'))
self.assertTrue(hasattr(ExecutionMode, 'SUBINTERP'))
self.assertTrue(hasattr(ExecutionMode, 'FREE_THREADED'))
class TestEventLoopPolicy(unittest.TestCase):
"""Test event loop policy installation."""
def setUp(self):
# Save original policy
self._original_policy = asyncio.get_event_loop_policy()
def tearDown(self):
# Restore original policy
asyncio.set_event_loop_policy(self._original_policy)
def test_set_event_loop_policy(self):
"""Test: asyncio.set_event_loop_policy(erlang.EventLoopPolicy())"""
erlang = _get_erlang_module()
# Install erlang event loop policy
asyncio.set_event_loop_policy(erlang.EventLoopPolicy())
# Verify policy is installed
policy = asyncio.get_event_loop_policy()
self.assertIsInstance(policy, erlang.EventLoopPolicy)
# Verify new loops use Erlang implementation
loop = asyncio.new_event_loop()
try:
# Run a simple coroutine
async def simple():
await asyncio.sleep(0.01)
return 42
result = loop.run_until_complete(simple())
self.assertEqual(result, 42)
finally:
loop.close()
def test_asyncio_run_with_policy(self):
"""Test: asyncio.run() with erlang policy installed."""
erlang = _get_erlang_module()
asyncio.set_event_loop_policy(erlang.EventLoopPolicy())
async def main():
await asyncio.sleep(0.01)
return "done"
result = asyncio.run(main())
self.assertEqual(result, "done")
class TestManualLoopSetup(unittest.TestCase):
"""Test manual event loop setup."""
def test_new_event_loop(self):
"""Test: erlang.new_event_loop() creates working loop."""
erlang = _get_erlang_module()
loop = erlang.new_event_loop()
try:
self.assertFalse(loop.is_closed())
self.assertFalse(loop.is_running())
async def simple():
return 123
result = loop.run_until_complete(simple())
self.assertEqual(result, 123)
finally:
loop.close()
self.assertTrue(loop.is_closed())
def test_set_event_loop_manual(self):
"""Test: asyncio.set_event_loop(erlang.new_event_loop())"""
erlang = _get_erlang_module()
loop = erlang.new_event_loop()
try:
asyncio.set_event_loop(loop)
# Verify it's the current loop (in Python 3.10+, this may raise a warning)
try:
current = asyncio.get_event_loop()
self.assertIs(current, loop)
except DeprecationWarning:
pass # Python 3.12+ deprecates get_event_loop without running loop
async def with_sleep():
await asyncio.sleep(0.01)
return "slept"
result = loop.run_until_complete(with_sleep())
self.assertEqual(result, "slept")
finally:
asyncio.set_event_loop(None)
loop.close()
def test_timers_work(self):
"""Test that call_later/asyncio.sleep use Erlang timers."""
erlang = _get_erlang_module()
loop = erlang.new_event_loop()
try:
results = []
def callback(x):
results.append(x)
if x == 3:
loop.stop()
# Schedule out of order - should execute in time order
loop.call_later(0.03, callback, 3)
loop.call_later(0.01, callback, 1)
loop.call_later(0.02, callback, 2)
loop.run_forever()
self.assertEqual(results, [1, 2, 3])
finally:
loop.close()
class TestErlangRun(unittest.TestCase):
"""Test erlang.run() function."""
def test_run_simple(self):
"""Test: erlang.run(coro)"""
erlang = _get_erlang_module()
async def simple():
return 42
result = erlang.run(simple())
self.assertEqual(result, 42)
def test_run_with_sleep(self):
"""Test: erlang.run() with asyncio.sleep()"""
erlang = _get_erlang_module()
async def with_sleep():
await asyncio.sleep(0.05)
return "done"
result = erlang.run(with_sleep())
self.assertEqual(result, "done")
def test_run_concurrent_tasks(self):
"""Test: erlang.run() with concurrent tasks."""
erlang = _get_erlang_module()
async def task(n):
await asyncio.sleep(0.01)
return n * 2
async def main():
results = await asyncio.gather(
task(1), task(2), task(3)
)
return results
result = erlang.run(main())
self.assertEqual(result, [2, 4, 6])
class TestErlangSleep(tb.ErlangTestCase):
"""Tests for erlang.sleep() function."""
def test_sleep_async_basic(self):
"""Test await erlang.sleep() in async context."""
erlang = _get_erlang_module()
async def main():
start = time.time()
await erlang.sleep(0.05)
elapsed = time.time() - start
return elapsed
elapsed = self.loop.run_until_complete(main())
# Should sleep at least 50ms (allowing some tolerance)
self.assertGreaterEqual(elapsed, 0.04)
# Should not sleep too long (sanity check)
self.assertLess(elapsed, 0.5)
def test_sleep_async_zero(self):
"""Test await erlang.sleep(0) yields but returns immediately."""
erlang = _get_erlang_module()
async def main():
start = time.time()
await erlang.sleep(0)
elapsed = time.time() - start
return elapsed
elapsed = self.loop.run_until_complete(main())
# Should return very quickly
self.assertLess(elapsed, 0.1)
def test_sleep_async_concurrent(self):
"""Test erlang.sleep() works correctly with concurrent tasks."""
erlang = _get_erlang_module()
async def task(n, sleep_time):
await erlang.sleep(sleep_time)
return n
async def main():
start = time.time()
# Run 3 tasks concurrently, each sleeping 0.05s
results = await asyncio.gather(
task(1, 0.05),
task(2, 0.05),
task(3, 0.05),
)
elapsed = time.time() - start
return results, elapsed
results, elapsed = self.loop.run_until_complete(main())
self.assertEqual(sorted(results), [1, 2, 3])
# Concurrent: should complete much faster than sequential (3 * 0.05s = 0.15s)
# Use generous tolerance for CI runner variance
self.assertLess(elapsed, 0.5)
def test_sleep_async_staggered(self):
"""Test erlang.sleep() with staggered sleep times."""
erlang = _get_erlang_module()
async def task(n, sleep_time):
await erlang.sleep(sleep_time)
return n
async def main():
# Tasks should complete in order of sleep time
results = []
tasks = [
asyncio.create_task(task(3, 0.06)),
asyncio.create_task(task(1, 0.02)),
asyncio.create_task(task(2, 0.04)),
]
for coro in asyncio.as_completed(tasks):
results.append(await coro)
return results
results = self.loop.run_until_complete(main())
# Should complete in order: 1 (0.02s), 2 (0.04s), 3 (0.06s)
self.assertEqual(results, [1, 2, 3])
def test_sleep_via_erlang_run(self):
"""Test erlang.sleep() works with erlang.run()."""
erlang = _get_erlang_module()
async def main():
start = time.time()
await erlang.sleep(0.03)
return time.time() - start
elapsed = erlang.run(main())
self.assertGreaterEqual(elapsed, 0.02)
self.assertLess(elapsed, 0.2)
def test_sleep_in_all_exported(self):
"""Test that sleep is exported in __all__."""
erlang = _get_erlang_module()
# Check via _erlang_impl since that's where __all__ is defined
try:
import _erlang_impl
self.assertIn('sleep', _erlang_impl.__all__)
except ImportError:
# If we can't import _erlang_impl directly, just check erlang has it
self.assertTrue(hasattr(erlang, 'sleep'))
class TestErlangSleepSync(unittest.TestCase):
"""Tests for erlang.sleep() in sync context.
Note: Sync sleep via Erlang callback only works when running
inside the Erlang NIF environment. These tests verify the API
exists and behaves correctly.
"""
def test_sleep_function_exists(self):
"""Test that erlang.sleep() function exists."""
erlang = _get_erlang_module()
self.assertTrue(hasattr(erlang, 'sleep'))
self.assertTrue(callable(erlang.sleep))
@unittest.skipUnless(tb.INSIDE_ERLANG_NIF, "Requires Erlang NIF environment")
def test_sleep_sync_basic(self):
"""Test erlang.sleep() in sync context (inside Erlang NIF)."""
erlang = _get_erlang_module()
start = time.time()
erlang.sleep(0.05)
elapsed = time.time() - start
# Should sleep at least 50ms
self.assertGreaterEqual(elapsed, 0.04)
self.assertLess(elapsed, 0.5)
@unittest.skipUnless(tb.INSIDE_ERLANG_NIF, "Requires Erlang NIF environment")
def test_sleep_sync_zero(self):
"""Test erlang.sleep(0) in sync context."""
erlang = _get_erlang_module()
start = time.time()
erlang.sleep(0)
elapsed = time.time() - start
# Should return very quickly
self.assertLess(elapsed, 0.1)
if __name__ == '__main__':
unittest.main()