diff --git a/workers/proxy_worker/dispatcher.py b/workers/proxy_worker/dispatcher.py index 0276a044..24c714a1 100644 --- a/workers/proxy_worker/dispatcher.py +++ b/workers/proxy_worker/dispatcher.py @@ -2,6 +2,7 @@ # Licensed under the MIT License. import asyncio +import importlib import logging import os import queue @@ -11,6 +12,7 @@ import typing from asyncio import AbstractEventLoop from dataclasses import dataclass +from importlib.metadata import entry_points from typing import Any, Optional import grpc @@ -30,6 +32,7 @@ check_python_eol ) from proxy_worker.utils.constants import ( + PYTHON_ENABLE_AGENT_RUNTIME, PYTHON_ENABLE_DEBUG_LOGGING, ) from proxy_worker.version import VERSION @@ -417,7 +420,76 @@ def stop(self) -> None: @staticmethod def reload_library_worker(directory: str): + """ + Load the appropriate runtime using the base package pattern. + + This uses the runtime base package to automatically discover which + runtime is loaded. + + If no runtime is registered via the base package, it falls back to + the traditional detection method. + """ global _library_worker, _library_worker_has_cv + + if is_envvar_true(PYTHON_ENABLE_AGENT_RUNTIME): + try: + # Import base package + import azurefunctions.extensions.base as runtime_base + # Discover all installed runtime packages via entry points + available_runtimes = list(entry_points(group='azurefunctions.runtimes')) + + # Only one runtime should be defined + if len(available_runtimes) > 1: + runtime_names = [ep.name for ep in available_runtimes] + raise RuntimeError( + f"Multiple runtimes detected: {runtime_names}. " + f"Only one runtime should be defined." + ) + + # Load the single runtime entry point if available + if available_runtimes: + ep = available_runtimes[0] + try: + # Load the entry point (triggers import and + # metaclass registration) + ep.load() + logger.debug(f"Loaded runtime entry point: {ep.name}") + except Exception as e: + logger.debug(f"Could not load runtime {ep.name}: {e}") + + # Check if a runtime was registered + # Check if the runtime base package has the RuntimeFeatureChecker + # Check if the runtime is loaded + if hasattr(runtime_base, 'RuntimeFeatureChecker') \ + and runtime_base.RuntimeFeatureChecker.runtime_loaded(): + # Get the registered runtime module + # (e.g., "azure_functions_fastapi.runtime") + runtime_module_name = ( + runtime_base.RuntimeTrackerMeta.get_module()) + runtime_name = ( + runtime_base.RuntimeTrackerMeta.get_runtime_name()) + package_name = ( + runtime_base.RuntimeTrackerMeta.get_package_name()) + + logger.debug("Runtime registered: %s (module: %s). " + "Importing runtime package: %s", + runtime_name, runtime_module_name, package_name) + + # Import the top-level runtime package (which exports + # the public API) + runtime_module = importlib.import_module(package_name) + _library_worker = runtime_module + _library_worker_has_cv = _library_worker.invocation_id_cv + # Module has been imported, end check + return + except ImportError: + logger.debug("ImportError when importing base extension: %s", + traceback.format_exc()) + + # No runtime registered via base package + # Use traditional detection + logger.debug( + "No runtime registered via base package, using fallback") v2_scriptfile = os.path.join(directory, get_script_file_name()) if os.path.exists(v2_scriptfile): try: diff --git a/workers/proxy_worker/utils/constants.py b/workers/proxy_worker/utils/constants.py index 3d6d0c64..409bad11 100644 --- a/workers/proxy_worker/utils/constants.py +++ b/workers/proxy_worker/utils/constants.py @@ -15,6 +15,8 @@ PYTHON_SCRIPT_FILE_NAME = "PYTHON_SCRIPT_FILE_NAME" PYTHON_SCRIPT_FILE_NAME_DEFAULT = "function_app.py" +PYTHON_ENABLE_AGENT_RUNTIME = "PYTHON_ENABLE_AGENT_RUNTIME" + # EOL Dates PYTHON_EOL_DATES = { '3.13': '2029-10', diff --git a/workers/tests/unittest_proxy/test_dispatcher.py b/workers/tests/unittest_proxy/test_dispatcher.py index eff94de0..a0ec1b54 100644 --- a/workers/tests/unittest_proxy/test_dispatcher.py +++ b/workers/tests/unittest_proxy/test_dispatcher.py @@ -1,6 +1,8 @@ import asyncio import builtins import logging +import os +import sys import threading import types import unittest @@ -18,6 +20,8 @@ get_thread_invocation_id, clear_thread_invocation_id, ) +from proxy_worker.utils.constants import PYTHON_ENABLE_AGENT_RUNTIME +import proxy_worker.dispatcher as dispatcher_module _real_import = builtins.__import__ @@ -61,8 +65,6 @@ def test_dispatcher_initialization(self, mock_thread, mock_queue): def test_on_logging_levels_and_categories(self, mock_is_system, mock_rpc_log, mock_streaming_message): # Import module to access cached constants - import proxy_worker.dispatcher as dispatcher_module - loop = Mock() dispatcher = Dispatcher(loop, "localhost", 5000, "worker", "req", 5.0) @@ -415,9 +417,6 @@ class TestInvocationTracking(unittest.TestCase): def setUp(self): """Clear any existing invocation state before each test""" - # Import the module-level variables properly - import proxy_worker.dispatcher as dispatcher_module - # Clear thread registry with dispatcher_module._registry_lock: dispatcher_module._thread_invocation_registry.clear() @@ -431,9 +430,6 @@ def setUp(self): def tearDown(self): """Clean up after each test""" - # Import the module-level variables properly - import proxy_worker.dispatcher as dispatcher_module - # Clear thread registry with dispatcher_module._registry_lock: dispatcher_module._thread_invocation_registry.clear() @@ -625,9 +621,6 @@ class TestDispatcherInvocationHandling(unittest.TestCase): def setUp(self): """Clear any existing invocation state before each test""" - # Import the module-level variables properly - import proxy_worker.dispatcher as dispatcher_module - # Clear thread registry with dispatcher_module._registry_lock: dispatcher_module._thread_invocation_registry.clear() @@ -638,9 +631,6 @@ def setUp(self): def tearDown(self): """Clean up after each test""" - # Import the module-level variables properly - import proxy_worker.dispatcher as dispatcher_module - # Clear thread registry with dispatcher_module._registry_lock: dispatcher_module._thread_invocation_registry.clear() @@ -774,3 +764,385 @@ async def test_invocation_request_handles_no_current_task( # Verify response self.assertEqual(result, "mocked_stream_response") + + +class TestReloadLibraryWorkerWithRuntimeBase(unittest.TestCase): + """Test suite for reload_library_worker with runtime base package pattern""" + + def setUp(self): + """Clear library worker state before each test""" + dispatcher_module._library_worker = None + dispatcher_module._library_worker_has_cv = False + # Enable agent runtime for these tests + os.environ[PYTHON_ENABLE_AGENT_RUNTIME] = "true" + + def tearDown(self): + """Clean up after each test""" + dispatcher_module._library_worker = None + dispatcher_module._library_worker_has_cv = False + # Clean up environment variable + if PYTHON_ENABLE_AGENT_RUNTIME in os.environ: + del os.environ[PYTHON_ENABLE_AGENT_RUNTIME] + + @patch("proxy_worker.dispatcher.logger") + @patch("proxy_worker.dispatcher.importlib.import_module") + @patch("proxy_worker.dispatcher.entry_points") + def test_runtime_base_success_with_runtime_suffix( + self, mock_entry_points, mock_import_module, mock_logger): + """Test successful runtime loading via base package with .runtime suffix""" + # Setup mock entry point + mock_ep = Mock() + mock_ep.name = "fastapi" + mock_ep.load = Mock() + mock_entry_points.return_value = [mock_ep] + + # Setup mock runtime base module + mock_runtime_base = Mock() + mock_runtime_base.RuntimeFeatureChecker.runtime_loaded.return_value = True + mock_runtime_base.RuntimeTrackerMeta.get_module.return_value = ( + "azure_functions_fastapi.runtime") + mock_runtime_base.RuntimeTrackerMeta.get_runtime_name.return_value = "fastapi" + mock_runtime_base.RuntimeTrackerMeta.get_package_name.return_value = ( + "azure_functions_fastapi") + + # Setup mock runtime module + mock_runtime_module = Mock() + mock_runtime_module.VERSION = "2.0.0" + mock_runtime_module.invocation_id_cv = Mock() + mock_import_module.return_value = mock_runtime_module + + # Patch the runtime base import + with patch.dict(sys.modules, { + 'azurefunctions.extensions.base': mock_runtime_base + }): + dispatcher_module.Dispatcher.reload_library_worker("/home/site/wwwroot") + + # Verify entry points were queried + mock_entry_points.assert_called_once_with(group='azurefunctions.runtimes') + + # Verify entry point was loaded + mock_ep.load.assert_called_once() + + # Verify runtime module was imported (package name extracted correctly) + mock_import_module.assert_called_once_with("azure_functions_fastapi") + + # Verify library worker was set + self.assertEqual(dispatcher_module._library_worker, mock_runtime_module) + self.assertTrue(dispatcher_module._library_worker_has_cv) + + # Verify logging + mock_logger.info.assert_any_call( + "Runtime registered: %s (module: %s)", + "fastapi", "azure_functions_fastapi.runtime" + ) + mock_logger.info.assert_any_call( + "Using runtime: %s, version: %s", + "fastapi", "2.0.0" + ) + + @patch("proxy_worker.dispatcher.logger") + @patch("proxy_worker.dispatcher.importlib.import_module") + @patch("proxy_worker.dispatcher.entry_points") + def test_runtime_base_success_without_runtime_suffix( + self, mock_entry_points, mock_import_module, mock_logger): + """Test successful runtime loading when module name has no .runtime suffix""" + # Setup mock entry point + mock_ep = Mock() + mock_ep.name = "custom_runtime" + mock_ep.load = Mock() + mock_entry_points.return_value = [mock_ep] + + # Setup mock runtime base module + mock_runtime_base = Mock() + mock_runtime_base.RuntimeFeatureChecker.runtime_loaded.return_value = True + mock_runtime_base.RuntimeTrackerMeta.get_module.return_value = "custom_package" + mock_runtime_base.RuntimeTrackerMeta.get_runtime_name.return_value = "custom" + mock_runtime_base.RuntimeTrackerMeta.get_package_name.return_value = ( + "custom_package") + + # Setup mock runtime module + mock_runtime_module = Mock() + mock_runtime_module.VERSION = "1.5.0" + mock_import_module.return_value = mock_runtime_module + + # Patch the runtime base import + with patch.dict(sys.modules, { + 'azurefunctions.extensions.base': mock_runtime_base + }): + dispatcher_module.Dispatcher.reload_library_worker("/home/site/wwwroot") + + # Verify package name extraction (fallback to first part) + mock_import_module.assert_called_once_with("custom_package") + + # Verify library worker was set + self.assertEqual(dispatcher_module._library_worker, mock_runtime_module) + + @patch("proxy_worker.dispatcher.logger") + @patch("proxy_worker.dispatcher.entry_points") + def test_runtime_base_entry_point_load_exception( + self, mock_entry_points, mock_logger): + """Test handling of exceptions when loading entry points""" + # Setup mock entry points - first fails, second succeeds + mock_ep1 = Mock() + mock_ep1.name = "broken_runtime" + mock_ep1.load.side_effect = Exception("Load failed") + + mock_ep2 = Mock() + mock_ep2.name = "good_runtime" + mock_ep2.load = Mock() + + mock_entry_points.return_value = [mock_ep1, mock_ep2] + + # Setup mock runtime base module + mock_runtime_base = Mock() + mock_runtime_base.RuntimeFeatureChecker.runtime_loaded.return_value = False + + # Patch the runtime base import and traditional fallback + with patch.dict(sys.modules, { + 'azurefunctions.extensions.base': mock_runtime_base + }): + with patch("proxy_worker.dispatcher.os.path.exists", + return_value=False): + with patch("builtins.__import__", + side_effect=ImportError("No fallback runtime")): + dispatcher_module.Dispatcher.reload_library_worker( + "/home/site/wwwroot") + + # Verify both entry points were attempted + mock_ep1.load.assert_called_once() + mock_ep2.load.assert_called_once() + + # Verify exception was logged + mock_logger.debug.assert_any_call( + "Could not load runtime %s: %s", + "broken_runtime", + ANY + ) + + @patch("proxy_worker.dispatcher.logger") + @patch("proxy_worker.dispatcher.entry_points") + @patch("proxy_worker.dispatcher.os.path.exists") + @patch("builtins.__import__") + def test_runtime_base_no_runtime_registered_fallback_to_v2( + self, mock_import, mock_exists, mock_entry_points, mock_logger): + """Test fallback to traditional v2 when no runtime registered""" + # Setup mock entry points (none succeed in registration) + mock_ep = Mock() + mock_ep.name = "test_runtime" + mock_ep.load = Mock() + mock_entry_points.return_value = [mock_ep] + + # Setup mock runtime base module - no runtime registered + mock_runtime_base = Mock() + mock_runtime_base.RuntimeFeatureChecker.runtime_loaded.return_value = False + + # Mock traditional fallback + mock_exists.return_value = True # v2 script exists + + mock_runtime_v2 = types.SimpleNamespace( + __file__="azure_functions_runtime.py", + invocation_id_cv=Mock() + ) + + def custom_import(name, *args, **kwargs): + if name == "azure_functions_runtime": + return mock_runtime_v2 + return _real_import(name, *args, **kwargs) + + mock_import.side_effect = custom_import + + # Clear sys.modules to force re-import + if 'azure_functions_runtime' in sys.modules: + del sys.modules['azure_functions_runtime'] + + # Patch the runtime base import + with patch.dict(sys.modules, { + 'azurefunctions.extensions.base': mock_runtime_base + }): + dispatcher_module.Dispatcher.reload_library_worker("/home/site/wwwroot") + + # Verify fallback logging + mock_logger.debug.assert_any_call( + "No runtime registered via base package, using fallback" + ) + mock_logger.debug.assert_any_call( + "azure_functions_runtime import succeeded: %s", + "azure_functions_runtime.py" + ) + + # Verify library worker was set to v2 + self.assertEqual(dispatcher_module._library_worker, mock_runtime_v2) + self.assertTrue(dispatcher_module._library_worker_has_cv) + + @patch("proxy_worker.dispatcher.logger") + @patch("proxy_worker.dispatcher.entry_points") + @patch("proxy_worker.dispatcher.os.path.exists") + @patch("builtins.__import__") + def test_runtime_base_no_runtime_registered_fallback_to_v1( + self, mock_import, mock_exists, mock_entry_points, mock_logger): + """Test fallback to traditional v1 when no runtime registered + and v2 script absent""" + # Setup mock entry points + mock_entry_points.return_value = [] + + # Setup mock runtime base module - no runtime registered + mock_runtime_base = Mock() + mock_runtime_base.RuntimeFeatureChecker.runtime_loaded.return_value = False + + # Mock traditional fallback - v2 script doesn't exist + mock_exists.return_value = False + + mock_runtime_v1 = types.SimpleNamespace( + __file__="azure_functions_runtime_v1.py" + ) + + def custom_import(name, *args, **kwargs): + if name == "azure_functions_runtime_v1": + return mock_runtime_v1 + return _real_import(name, *args, **kwargs) + + mock_import.side_effect = custom_import + + # Clear sys.modules to force re-import + if 'azure_functions_runtime_v1' in sys.modules: + del sys.modules['azure_functions_runtime_v1'] + + # Patch the runtime base import + with patch.dict(sys.modules, { + 'azurefunctions.extensions.base': mock_runtime_base + }): + dispatcher_module.Dispatcher.reload_library_worker("/home/site/wwwroot") + + # Verify fallback logging + mock_logger.debug.assert_any_call( + "azure_functions_runtime_v1 import succeeded: %s", + "azure_functions_runtime_v1.py" + ) + + # Verify library worker was set to v1 + self.assertEqual(dispatcher_module._library_worker, mock_runtime_v1) + self.assertFalse(dispatcher_module._library_worker_has_cv) + + @patch("proxy_worker.dispatcher.logger") + @patch("proxy_worker.dispatcher.os.path.exists") + @patch("builtins.__import__") + def test_runtime_base_import_error_fallback_to_traditional( + self, mock_import, mock_exists, mock_logger): + """Test fallback when runtime base package import fails""" + # Mock runtime base import failure - raise error when importing base package + def custom_import(name, *args, **kwargs): + if "azurefunctions.extensions.base" in name: + raise ImportError("Runtime base not installed") + if name == "azure_functions_runtime": + mock_runtime = types.SimpleNamespace( + __file__="azure_functions_runtime.py", + invocation_id_cv=Mock() + ) + return mock_runtime + return _real_import(name, *args, **kwargs) + + mock_import.side_effect = custom_import + mock_exists.return_value = True + + # Clear sys.modules to force re-import + if 'azure_functions_runtime' in sys.modules: + del sys.modules['azure_functions_runtime'] + + dispatcher_module.Dispatcher.reload_library_worker("/home/site/wwwroot") + + # Verify error was logged + mock_logger.error.assert_called_once() + self.assertIn("Failed to import runtime base package", + str(mock_logger.error.call_args)) + + @patch("proxy_worker.dispatcher.logger") + @patch("proxy_worker.dispatcher.importlib.import_module") + @patch("proxy_worker.dispatcher.entry_points") + def test_runtime_base_multiple_entry_points( + self, mock_entry_points, mock_import_module, mock_logger): + """Test handling of multiple entry points (only first + registered runtime used)""" + # Setup multiple mock entry points + mock_ep1 = Mock() + mock_ep1.name = "runtime1" + mock_ep1.load = Mock() + + mock_ep2 = Mock() + mock_ep2.name = "runtime2" + mock_ep2.load = Mock() + + mock_entry_points.return_value = [mock_ep1, mock_ep2] + + # Setup mock runtime base module - runtime registered after first load + call_count = [0] + + def runtime_loaded_side_effect(): + call_count[0] += 1 + return call_count[0] == 1 # True after first load + + mock_runtime_base = Mock() + mock_runtime_base.RuntimeFeatureChecker.runtime_loaded.side_effect = ( + runtime_loaded_side_effect) + mock_runtime_base.RuntimeTrackerMeta.get_module.return_value = ( + "runtime1_package.runtime") + mock_runtime_base.RuntimeTrackerMeta.get_runtime_name.return_value = "runtime1" + mock_runtime_base.RuntimeTrackerMeta.get_package_name.return_value = ( + "runtime1_package") + + # Setup mock runtime module + mock_runtime_module = Mock() + mock_runtime_module.VERSION = "1.0.0" + mock_import_module.return_value = mock_runtime_module + + # Patch the runtime base import + with patch.dict(sys.modules, { + 'azurefunctions.extensions.base': mock_runtime_base + }): + dispatcher_module.Dispatcher.reload_library_worker("/home/site/wwwroot") + + # Verify first entry point was loaded + mock_ep1.load.assert_called_once() + + # Note: In the actual implementation, all entry points are loaded + # but only the first registered runtime is used + # This test documents that behavior + self.assertEqual(dispatcher_module._library_worker, mock_runtime_module) + + @patch("proxy_worker.dispatcher.logger") + @patch("proxy_worker.dispatcher.importlib.import_module") + @patch("proxy_worker.dispatcher.entry_points") + def test_runtime_base_version_unknown( + self, mock_entry_points, mock_import_module, mock_logger): + """Test handling when runtime module has no VERSION attribute""" + # Setup mock entry point + mock_ep = Mock() + mock_ep.name = "no_version_runtime" + mock_ep.load = Mock() + mock_entry_points.return_value = [mock_ep] + + # Setup mock runtime base module + mock_runtime_base = Mock() + mock_runtime_base.RuntimeFeatureChecker.runtime_loaded.return_value = True + mock_runtime_base.RuntimeTrackerMeta.get_module.return_value = ( + "no_version_package.runtime") + mock_runtime_base.RuntimeTrackerMeta.get_runtime_name.return_value = ( + "no_version") + mock_runtime_base.RuntimeTrackerMeta.get_package_name.return_value = ( + "no_version_package") + + # Setup mock runtime module without VERSION + mock_runtime_module = Mock(spec=[]) # No attributes + del mock_runtime_module.VERSION # Ensure VERSION doesn't exist + mock_import_module.return_value = mock_runtime_module + + # Patch the runtime base import - use sys directly instead of string + with patch.dict(sys.modules, { + 'azurefunctions.extensions.base': mock_runtime_base + }): + dispatcher_module.Dispatcher.reload_library_worker("/home/site/wwwroot") + + # Verify "unknown" is used when VERSION is missing + mock_logger.info.assert_any_call( + "Using runtime: %s, version: %s", + "no_version", "unknown" + ) diff --git a/workers/tests/unittest_proxy/test_dispatcher_agent_runtime.py b/workers/tests/unittest_proxy/test_dispatcher_agent_runtime.py new file mode 100644 index 00000000..dbbd546a --- /dev/null +++ b/workers/tests/unittest_proxy/test_dispatcher_agent_runtime.py @@ -0,0 +1,393 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +""" +Unit tests for PYTHON_ENABLE_AGENT_RUNTIME environment variable logic +in dispatcher.reload_library_worker +""" +import builtins +import os +import sys +import types +import unittest +from unittest.mock import Mock, patch + +from proxy_worker.utils.constants import PYTHON_ENABLE_AGENT_RUNTIME +import proxy_worker.dispatcher as dispatcher_module + + +_real_import = builtins.__import__ + + +class TestReloadLibraryWorkerAgentRuntime(unittest.TestCase): + """Test suite for reload_library_worker with PYTHON_ENABLE_AGENT_RUNTIME env var""" + + def setUp(self): + """Clear library worker state and environment before each test""" + dispatcher_module._library_worker = None + dispatcher_module._library_worker_has_cv = False + + # Clear environment variable + if PYTHON_ENABLE_AGENT_RUNTIME in os.environ: + del os.environ[PYTHON_ENABLE_AGENT_RUNTIME] + + def tearDown(self): + """Clean up after each test""" + dispatcher_module._library_worker = None + dispatcher_module._library_worker_has_cv = False + + # Clear environment variable + if PYTHON_ENABLE_AGENT_RUNTIME in os.environ: + del os.environ[PYTHON_ENABLE_AGENT_RUNTIME] + + @patch("proxy_worker.dispatcher.logger") + @patch("proxy_worker.dispatcher.importlib.import_module") + @patch("proxy_worker.dispatcher.entry_points") + def test_agent_runtime_enabled_with_true_string( + self, mock_entry_points, mock_import_module, mock_logger): + """Test that entry points are used when PYTHON_ENABLE_AGENT_RUNTIME='true'""" + # Set environment variable to enable agent runtime + os.environ[PYTHON_ENABLE_AGENT_RUNTIME] = "true" + + # Setup mock entry point + mock_ep = Mock() + mock_ep.name = "fastapi" + mock_ep.load = Mock() + mock_entry_points.return_value = [mock_ep] + + # Setup mock runtime base module + mock_runtime_base = Mock() + mock_runtime_base.RuntimeFeatureChecker.runtime_loaded.return_value = True + mock_runtime_base.RuntimeTrackerMeta.get_module.return_value = ( + "azure_functions_fastapi.runtime") + mock_runtime_base.RuntimeTrackerMeta.get_runtime_name.return_value = "fastapi" + mock_runtime_base.RuntimeTrackerMeta.get_package_name.return_value = ( + "azure_functions_fastapi") + + # Setup mock runtime module + mock_runtime_module = Mock() + mock_runtime_module.VERSION = "2.0.0" + mock_import_module.return_value = mock_runtime_module + + # Patch the runtime base import + with patch.dict(sys.modules, { + 'azurefunctions.extensions.base': mock_runtime_base + }): + dispatcher_module.Dispatcher.reload_library_worker("/home/site/wwwroot") + + # Verify entry points were queried + mock_entry_points.assert_called_once_with(group='azurefunctions.runtimes') + + # Verify entry point was loaded + mock_ep.load.assert_called_once() + + # Verify runtime module was imported + mock_import_module.assert_called_once_with("azure_functions_fastapi") + + # Verify library worker was set + self.assertEqual(dispatcher_module._library_worker, mock_runtime_module) + + @patch("proxy_worker.dispatcher.logger") + @patch("proxy_worker.dispatcher.importlib.import_module") + @patch("proxy_worker.dispatcher.entry_points") + def test_agent_runtime_enabled_with_1_string( + self, mock_entry_points, mock_import_module, mock_logger): + """Test that entry points are used when PYTHON_ENABLE_AGENT_RUNTIME='1'""" + # Set environment variable to enable agent runtime + os.environ[PYTHON_ENABLE_AGENT_RUNTIME] = "1" + + # Setup mock entry point + mock_ep = Mock() + mock_ep.name = "test_runtime" + mock_ep.load = Mock() + mock_entry_points.return_value = [mock_ep] + + # Setup mock runtime base module + mock_runtime_base = Mock() + mock_runtime_base.RuntimeFeatureChecker.runtime_loaded.return_value = True + mock_runtime_base.RuntimeTrackerMeta.get_module.return_value = ( + "test_package.runtime") + mock_runtime_base.RuntimeTrackerMeta.get_runtime_name.return_value = ( + "test") + mock_runtime_base.RuntimeTrackerMeta.get_package_name.return_value = ( + "test_package") + + # Setup mock runtime module + mock_runtime_module = Mock() + mock_runtime_module.VERSION = "1.0.0" + mock_import_module.return_value = mock_runtime_module + + # Patch the runtime base import + with patch.dict(sys.modules, { + 'azurefunctions.extensions.base': mock_runtime_base + }): + dispatcher_module.Dispatcher.reload_library_worker("/home/site/wwwroot") + + # Verify entry points were queried (agent runtime path was used) + mock_entry_points.assert_called_once_with( + group='azurefunctions.runtimes') + + # Verify library worker was set + self.assertEqual(dispatcher_module._library_worker, mock_runtime_module) + + @patch("proxy_worker.dispatcher.logger") + @patch("proxy_worker.dispatcher.entry_points") + @patch("proxy_worker.dispatcher.os.path.exists") + @patch("builtins.__import__") + def test_agent_runtime_disabled_uses_traditional_detection( + self, mock_import, mock_exists, mock_entry_points, mock_logger): + """Test that traditional detection is used when + PYTHON_ENABLE_AGENT_RUNTIME is not set""" + # Do NOT set PYTHON_ENABLE_AGENT_RUNTIME - should use traditional detection + + # Mock traditional fallback + mock_exists.return_value = True # v2 script exists + + mock_runtime_v2 = types.SimpleNamespace( + __file__="azure_functions_runtime.py", + invocation_id_cv=Mock(), + VERSION="1.10.0" + ) + + def custom_import(name, *args, **kwargs): + if name == "azure_functions_runtime": + return mock_runtime_v2 + return _real_import(name, *args, **kwargs) + + mock_import.side_effect = custom_import + + # Clear sys.modules to force re-import + if 'azure_functions_runtime' in sys.modules: + del sys.modules['azure_functions_runtime'] + + dispatcher_module.Dispatcher.reload_library_worker("/home/site/wwwroot") + + # Verify entry points were NOT queried + mock_entry_points.assert_not_called() + + # Verify library worker was set to v2 via traditional detection + self.assertEqual(dispatcher_module._library_worker, mock_runtime_v2) + self.assertTrue(dispatcher_module._library_worker_has_cv) + + @patch("proxy_worker.dispatcher.logger") + @patch("proxy_worker.dispatcher.entry_points") + @patch("proxy_worker.dispatcher.os.path.exists") + @patch("builtins.__import__") + def test_agent_runtime_disabled_with_false_string( + self, mock_import, mock_exists, mock_entry_points, mock_logger): + """Test that traditional detection is used when + PYTHON_ENABLE_AGENT_RUNTIME='false'""" + # Set environment variable to disable agent runtime + os.environ[PYTHON_ENABLE_AGENT_RUNTIME] = "false" + + # Mock traditional fallback + mock_exists.return_value = False # v2 script doesn't exist + + mock_runtime_v1 = types.SimpleNamespace( + __file__="azure_functions_runtime_v1.py", + VERSION="1.0.0" + ) + + def custom_import(name, *args, **kwargs): + if name == "azure_functions_runtime_v1": + return mock_runtime_v1 + return _real_import(name, *args, **kwargs) + + mock_import.side_effect = custom_import + + # Clear sys.modules to force re-import + if 'azure_functions_runtime_v1' in sys.modules: + del sys.modules['azure_functions_runtime_v1'] + + dispatcher_module.Dispatcher.reload_library_worker("/home/site/wwwroot") + + # Verify entry points were NOT queried + mock_entry_points.assert_not_called() + + # Verify library worker was set to v1 via traditional detection + self.assertEqual(dispatcher_module._library_worker, mock_runtime_v1) + self.assertFalse(dispatcher_module._library_worker_has_cv) + + @patch("proxy_worker.dispatcher.logger") + @patch("proxy_worker.dispatcher.entry_points") + @patch("proxy_worker.dispatcher.os.path.exists") + @patch("builtins.__import__") + def test_agent_runtime_disabled_with_0_string( + self, mock_import, mock_exists, mock_entry_points, mock_logger): + """Test that traditional detection is used when + PYTHON_ENABLE_AGENT_RUNTIME='0'""" + # Set environment variable to disable agent runtime + os.environ[PYTHON_ENABLE_AGENT_RUNTIME] = "0" + + # Mock traditional fallback + mock_exists.return_value = True + + mock_runtime_v2 = types.SimpleNamespace( + __file__="azure_functions_runtime.py", + invocation_id_cv=Mock(), + VERSION="1.11.0" + ) + + def custom_import(name, *args, **kwargs): + if name == "azure_functions_runtime": + return mock_runtime_v2 + return _real_import(name, *args, **kwargs) + + mock_import.side_effect = custom_import + + # Clear sys.modules to force re-import + if 'azure_functions_runtime' in sys.modules: + del sys.modules['azure_functions_runtime'] + + dispatcher_module.Dispatcher.reload_library_worker("/home/site/wwwroot") + + # Verify entry points were NOT queried + mock_entry_points.assert_not_called() + + # Verify library worker was set via traditional detection + self.assertEqual(dispatcher_module._library_worker, mock_runtime_v2) + + @patch("proxy_worker.dispatcher.logger") + @patch("proxy_worker.dispatcher.entry_points") + def test_agent_runtime_enabled_no_runtime_registered_fallback_still_used( + self, mock_entry_points, mock_logger): + """Test that when agent runtime is enabled but no runtime registers, + we still use the traditional fallback within the agent runtime path""" + # Set environment variable to enable agent runtime + os.environ[PYTHON_ENABLE_AGENT_RUNTIME] = "true" + + # Setup mock entry points + mock_entry_points.return_value = [] + + # Setup mock runtime base module - no runtime registered + mock_runtime_base = Mock() + mock_runtime_base.RuntimeFeatureChecker.runtime_loaded.return_value = False + + # Mock traditional fallback + with patch("proxy_worker.dispatcher.os.path.exists", return_value=True): + with patch("builtins.__import__") as mock_import: + mock_runtime_v2 = types.SimpleNamespace( + __file__="azure_functions_runtime.py", + invocation_id_cv=Mock(), + VERSION="1.12.0" + ) + + def custom_import(name, *args, **kwargs): + if name == "azure_functions_runtime": + return mock_runtime_v2 + return _real_import(name, *args, **kwargs) + + mock_import.side_effect = custom_import + + # Patch the runtime base import + with patch.dict(sys.modules, { + 'azurefunctions.extensions.base': mock_runtime_base + }): + dispatcher_module.Dispatcher.reload_library_worker( + "/home/site/wwwroot") + + # When agent runtime is enabled, entry points are queried even if + # no runtime registers + mock_entry_points.assert_called_once_with(group='azurefunctions.runtimes') + + # Based on the updated implementation, when no runtime registers via + # entry points, the dispatcher falls back to traditional detection + # So we should have the v2 runtime loaded + self.assertEqual(dispatcher_module._library_worker, mock_runtime_v2) + self.assertTrue(dispatcher_module._library_worker_has_cv) + + @patch("proxy_worker.dispatcher.logger") + @patch("proxy_worker.dispatcher.importlib.import_module") + @patch("proxy_worker.dispatcher.entry_points") + def test_agent_runtime_enabled_logs_debug_messages( + self, mock_entry_points, mock_import_module, mock_logger): + """Test that appropriate debug messages are logged when + agent runtime is enabled""" + # Set environment variable to enable agent runtime + os.environ[PYTHON_ENABLE_AGENT_RUNTIME] = "1" + + # Setup mock entry point + mock_ep = Mock() + mock_ep.name = "fastapi" + mock_ep.load = Mock() + mock_entry_points.return_value = [mock_ep] + + # Setup mock runtime base module + mock_runtime_base = Mock() + mock_runtime_base.RuntimeFeatureChecker.runtime_loaded.return_value = True + mock_runtime_base.RuntimeTrackerMeta.get_module.return_value = ( + "azure_functions_fastapi.runtime") + mock_runtime_base.RuntimeTrackerMeta.get_runtime_name.return_value = "fastapi" + mock_runtime_base.RuntimeTrackerMeta.get_package_name.return_value = ( + "azure_functions_fastapi") + + # Setup mock runtime module + mock_runtime_module = Mock() + mock_runtime_module.VERSION = "2.5.0" + mock_import_module.return_value = mock_runtime_module + + # Patch the runtime base import + with patch.dict(sys.modules, { + 'azurefunctions.extensions.base': mock_runtime_base + }): + dispatcher_module.Dispatcher.reload_library_worker("/home/site/wwwroot") + + # Verify debug logging for loaded entry point + mock_logger.debug.assert_any_call( + f"Loaded runtime entry point: {mock_ep.name}") + + # Verify info logging for runtime registration (changed from debug to info) + mock_logger.info.assert_any_call( + "Runtime registered: %s (module: %s)", + "fastapi", + "azure_functions_fastapi.runtime" + ) + + # Verify info logging for runtime version + mock_logger.info.assert_any_call( + "Using runtime: %s, version: %s", + "fastapi", + "2.5.0" + ) + + @patch("proxy_worker.dispatcher.logger") + @patch("proxy_worker.dispatcher.os.path.exists") + @patch("builtins.__import__") + def test_agent_runtime_disabled_logs_debug_fallback_message( + self, mock_import, mock_exists, mock_logger): + """Test that fallback message is logged when agent runtime is disabled""" + # Ensure agent runtime is disabled (not set) + if PYTHON_ENABLE_AGENT_RUNTIME in os.environ: + del os.environ[PYTHON_ENABLE_AGENT_RUNTIME] + + # Mock traditional fallback + mock_exists.return_value = True + + mock_runtime_v2 = types.SimpleNamespace( + __file__="azure_functions_runtime.py", + invocation_id_cv=Mock(), + VERSION="1.13.0" + ) + + def custom_import(name, *args, **kwargs): + if name == "azure_functions_runtime": + return mock_runtime_v2 + return _real_import(name, *args, **kwargs) + + mock_import.side_effect = custom_import + + # Clear sys.modules to force re-import + if 'azure_functions_runtime' in sys.modules: + del sys.modules['azure_functions_runtime'] + + dispatcher_module.Dispatcher.reload_library_worker("/home/site/wwwroot") + + # Verify fallback debug message is logged + mock_logger.debug.assert_any_call( + "No runtime registered via base package, using fallback" + ) + + # Verify traditional runtime import logged + mock_logger.debug.assert_any_call( + "azure_functions_runtime import succeeded: %s", + "azure_functions_runtime.py" + )