diff --git a/workers/proxy_worker/utils/common.py b/workers/proxy_worker/utils/common.py index 3740658a..ffb4e484 100644 --- a/workers/proxy_worker/utils/common.py +++ b/workers/proxy_worker/utils/common.py @@ -7,6 +7,8 @@ from typing import Callable, Optional from proxy_worker.utils.constants import ( + AZURE_CONTAINER_NAME, + AZURE_WEBSITE_INSTANCE_ID, PYTHON_SCRIPT_FILE_NAME, PYTHON_SCRIPT_FILE_NAME_DEFAULT, PYTHON_EOL_DATES, @@ -115,3 +117,9 @@ def check_python_eol(): logger.warning(f"Python {version} will reach EOL on " f"{eol_date.strftime('%Y-%m')}. Consider upgrading to " f"a supported version: aka.ms/supported-python-versions") + + +def is_azure_environment(): + """Check if the function app is running on the cloud""" + return (AZURE_CONTAINER_NAME in os.environ + or AZURE_WEBSITE_INSTANCE_ID in os.environ) diff --git a/workers/proxy_worker/utils/constants.py b/workers/proxy_worker/utils/constants.py index 53ad55ae..3d6d0c64 100644 --- a/workers/proxy_worker/utils/constants.py +++ b/workers/proxy_worker/utils/constants.py @@ -7,6 +7,8 @@ # Container constants CONTAINER_NAME = "CONTAINER_NAME" +AZURE_WEBSITE_INSTANCE_ID = "WEBSITE_INSTANCE_ID" +AZURE_CONTAINER_NAME = "CONTAINER_NAME" AZURE_WEBJOBS_SCRIPT_ROOT = "AzureWebJobsScriptRoot" # new programming model default script file name diff --git a/workers/proxy_worker/utils/dependency.py b/workers/proxy_worker/utils/dependency.py index ea165785..24040e57 100644 --- a/workers/proxy_worker/utils/dependency.py +++ b/workers/proxy_worker/utils/dependency.py @@ -7,7 +7,7 @@ from typing import List, Optional from ..logging import logger -from .common import is_envvar_true +from .common import is_envvar_true, is_azure_environment from .constants import AZURE_WEBJOBS_SCRIPT_ROOT, CONTAINER_NAME @@ -136,7 +136,7 @@ def prioritize_customer_dependencies(cls, cx_working_dir=None): cls._remove_from_sys_path(cls.worker_deps_path) cls._add_to_sys_path(cls.worker_deps_path, True) - cls._add_to_sys_path(cls.cx_deps_path, True) + cls._add_cx_deps_to_sys_path(cls.cx_deps_path, True) cls._add_to_sys_path(working_directory, False) logger.info( @@ -170,6 +170,45 @@ def _add_to_sys_path(cls, path: str, add_to_first: bool): # defined in sys.path cls._clear_path_importer_cache_and_modules(path) + @classmethod + def _add_cx_deps_to_sys_path(cls, path: str, add_to_first: bool): + """This will ensure no duplicated path are added into sys.path and + clear importer cache. No action if path already exists in sys.path. + + Parameters + ---------- + path: str + The path needs to be added into sys.path. + If the path is an empty string, no action will be taken. + add_to_first: bool + Should the path added to the first entry (highest priority) + """ + + # Customer dependencies path has not been identified & app is not in + # Azure environment -> app is running locally with an environment not + # in Function App level + if not path and not is_azure_environment(): + default_path = next((p for p in sys.path if 'site-packages' in p), '') + logger.info("No customer dependencies path found, using default: %s", + default_path) + if default_path not in sys.path: + sys.path.insert(0, default_path) + # Don't duplicate paths - move to front without clearing cache + else: + sys.path.remove(default_path) + sys.path.insert(0, default_path) + + # Otherwise, continue with normal flow + elif path and path not in sys.path: + if add_to_first: + sys.path.insert(0, path) + else: + sys.path.append(path) + + # Only clear path importer and sys.modules cache if path is not + # defined in sys.path + cls._clear_path_importer_cache_and_modules(path) + @classmethod def _remove_from_sys_path(cls, path: str): """This will remove path from sys.path and clear importer cache. diff --git a/workers/tests/unittest_proxy/test_dependency.py b/workers/tests/unittest_proxy/test_dependency.py index 6cb4edca..da33c065 100644 --- a/workers/tests/unittest_proxy/test_dependency.py +++ b/workers/tests/unittest_proxy/test_dependency.py @@ -58,3 +58,222 @@ def test_prioritize_customer_dependencies(mock_logger, mock_env, mock_linux, "Finished prioritize_customer_dependencies" in str(call[0][0]) for call in mock_logger.info.call_args_list ) + + +@patch.dict(os.environ, {"AzureWebJobsScriptRoot": "/home/site/wwwroot"}) +def test_get_cx_deps_path_with_matching_prefix(): + """Test _get_cx_deps_path returns customer path when prefix matches.""" + original_sys_path = sys.path.copy() + try: + sys.path = [ + "/home/site/wwwroot/.python_packages/lib/site-packages", + "/usr/local/lib/python3.11/site-packages", + "/home/site/wwwroot" + ] + result = DependencyManager._get_cx_deps_path() + + assert result == "/home/site/wwwroot/.python_packages/lib/site-packages" + finally: + sys.path = original_sys_path + + +@patch.dict(os.environ, {"AzureWebJobsScriptRoot": "/home/site/wwwroot"}) +def test_get_cx_deps_path_no_matching_prefix_returns_empty(): + """Test _get_cx_deps_path returns empty string when no prefix match.""" + original_sys_path = sys.path.copy() + try: + sys.path = [ + "/usr/local/lib/python3.11/site-packages", + "/some/other/path", + "/home/site/wwwroot" + ] + result = DependencyManager._get_cx_deps_path() + + # When no cx_paths match, return empty string (not the first site-packages) + assert result == "" + finally: + sys.path = original_sys_path + + +@patch.dict(os.environ, {}, clear=True) +def test_get_cx_deps_path_no_prefix_env_returns_empty(): + """Test _get_cx_deps_path returns empty string when no env var set.""" + original_sys_path = sys.path.copy() + try: + sys.path = [ + "/usr/local/lib/python3.11/site-packages", + "/some/other/path" + ] + result = DependencyManager._get_cx_deps_path() + + # When env var is not set, prefix is None and cx_paths is empty + assert result == "" + finally: + sys.path = original_sys_path + + +@patch.dict(os.environ, {"AzureWebJobsScriptRoot": "/home/site/wwwroot"}) +def test_get_cx_deps_path_no_site_packages_returns_empty(): + """Test _get_cx_deps_path returns empty string when no site-packages found.""" + original_sys_path = sys.path.copy() + try: + sys.path = [ + "/home/site/wwwroot", + "/some/other/path" + ] + result = DependencyManager._get_cx_deps_path() + + # When no paths with site-packages match the prefix, return empty string + assert result == "" + finally: + sys.path = original_sys_path + + +@patch.dict(os.environ, {"AzureWebJobsScriptRoot": "/home/site/wwwroot"}) +def test_get_cx_deps_path_multiple_matches_returns_first(): + """Test _get_cx_deps_path returns first match when multiple cx paths exist.""" + original_sys_path = sys.path.copy() + try: + sys.path = [ + "/home/site/wwwroot/.python_packages/lib/site-packages", + "/home/site/wwwroot/venv/lib/site-packages", + "/usr/local/lib/python3.11/site-packages" + ] + result = DependencyManager._get_cx_deps_path() + + # When multiple paths match, return the first one + expected_path = "/home/site/wwwroot/.python_packages/lib/site-packages" + assert result == expected_path + finally: + sys.path = original_sys_path + + +@patch( + "proxy_worker.utils.dependency.DependencyManager." + "_clear_path_importer_cache_and_modules" +) +def test_add_cx_deps_to_sys_path_adds_to_first(mock_clear): + """Test _add_cx_deps_to_sys_path adds path to first position.""" + sys.path = ["/original/path", "/another/path"] + + DependencyManager._add_cx_deps_to_sys_path( + "/new/cx/path", add_to_first=True + ) + + assert sys.path[0] == "/new/cx/path" + assert "/original/path" in sys.path + mock_clear.assert_called_once_with("/new/cx/path") + + +@patch( + "proxy_worker.utils.dependency.DependencyManager." + "_clear_path_importer_cache_and_modules" +) +def test_add_cx_deps_to_sys_path_appends_to_end(mock_clear): + """Test _add_cx_deps_to_sys_path appends path to end.""" + sys.path = ["/original/path", "/another/path"] + + DependencyManager._add_cx_deps_to_sys_path( + "/new/cx/path", add_to_first=False + ) + + assert sys.path[-1] == "/new/cx/path" + assert sys.path[0] == "/original/path" + mock_clear.assert_called_once_with("/new/cx/path") + + +def test_add_cx_deps_to_sys_path_no_duplicate(): + """Test _add_cx_deps_to_sys_path does not add duplicate paths.""" + sys.path = ["/existing/path", "/another/path"] + original_length = len(sys.path) + + DependencyManager._add_cx_deps_to_sys_path( + "/existing/path", add_to_first=True + ) + + # Path should not be added again + assert len(sys.path) == original_length + assert sys.path.count("/existing/path") == 1 + + +@patch("proxy_worker.utils.dependency.is_azure_environment", + return_value=False) +@patch("proxy_worker.utils.dependency.logger") +def test_add_cx_deps_to_sys_path_empty_path_with_default( + mock_logger, mock_is_azure +): + """Test _add_cx_deps_to_sys_path uses default when path is empty + in local environment.""" + sys.path = ["/usr/local/lib/python3.11/site-packages", "/original/path"] + + DependencyManager._add_cx_deps_to_sys_path("", add_to_first=True) + + # Should insert the first site-packages path to position 0 + assert sys.path[0] == "/usr/local/lib/python3.11/site-packages" + mock_logger.info.assert_called_once_with( + "No customer dependencies path found, using default: %s", + "/usr/local/lib/python3.11/site-packages" + ) + mock_is_azure.assert_called_once() + + +@patch("proxy_worker.utils.dependency.is_azure_environment", + return_value=False) +@patch("proxy_worker.utils.dependency.logger") +def test_add_cx_deps_to_sys_path_empty_path_no_site_packages( + mock_logger, mock_is_azure +): + """Test _add_cx_deps_to_sys_path handles empty path with no + site-packages in local environment.""" + sys.path = ["/some/path", "/another/path"] + + DependencyManager._add_cx_deps_to_sys_path("", add_to_first=True) + + # Should insert empty string at position 0 when no site-packages found + assert sys.path[0] == "" + mock_logger.info.assert_called_once_with( + "No customer dependencies path found, using default: %s", + "" + ) + mock_is_azure.assert_called_once() + + +@patch("proxy_worker.utils.dependency.is_azure_environment", + return_value=True) +@patch("proxy_worker.utils.dependency.logger") +def test_add_cx_deps_to_sys_path_empty_path_in_azure( + mock_logger, mock_is_azure +): + """Test _add_cx_deps_to_sys_path takes no action when path is empty + in Azure environment.""" + sys.path = ["/usr/local/lib/python3.11/site-packages", "/original/path"] + original_sys_path = sys.path.copy() + + DependencyManager._add_cx_deps_to_sys_path("", add_to_first=True) + + # sys.path should remain unchanged in Azure environment + assert sys.path == original_sys_path + mock_logger.info.assert_not_called() + mock_is_azure.assert_called_once() + + +@patch("proxy_worker.utils.dependency.is_azure_environment", + return_value=True) +@patch( + "proxy_worker.utils.dependency.DependencyManager." + "_clear_path_importer_cache_and_modules" +) +def test_add_cx_deps_to_sys_path_none_path_no_action( + mock_clear, mock_is_azure +): + """Test _add_cx_deps_to_sys_path takes no action for None path + in Azure environment.""" + sys.path = ["/original/path"] + original_sys_path = sys.path.copy() + + DependencyManager._add_cx_deps_to_sys_path(None, add_to_first=True) + + # sys.path should remain unchanged + assert sys.path == original_sys_path + mock_clear.assert_not_called() + mock_is_azure.assert_called_once()