Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions workers/proxy_worker/utils/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
2 changes: 2 additions & 0 deletions workers/proxy_worker/utils/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 41 additions & 2 deletions workers/proxy_worker/utils/dependency.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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.
Expand Down
219 changes: 219 additions & 0 deletions workers/tests/unittest_proxy/test_dependency.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading