From a1fb445c0269967629a18a0b4250e2af3cecb627 Mon Sep 17 00:00:00 2001 From: Gavin Aguiar Date: Wed, 5 Feb 2025 15:41:54 -0600 Subject: [PATCH 01/42] Proxy Worker: Initial Commit --- proxy_worker/__init__.py | 0 proxy_worker/__main__.py | 6 + proxy_worker/dispatcher.py | 523 +++++++++++++++++++++++ proxy_worker/logging.py | 92 ++++ proxy_worker/protos/.gitignore | 3 + proxy_worker/protos/__init__.py | 43 ++ proxy_worker/protos/identity/__init__.py | 0 proxy_worker/protos/shared/__init__.py | 0 proxy_worker/start_worker.py | 84 ++++ proxy_worker/utils/__init__.py | 0 proxy_worker/utils/app_settings.py | 72 ++++ proxy_worker/utils/common.py | 29 ++ proxy_worker/utils/constants.py | 11 + proxy_worker/utils/dependency.py | 370 ++++++++++++++++ proxy_worker/version.py | 1 + python/proxyV4/worker.py | 64 +++ 16 files changed, 1298 insertions(+) create mode 100644 proxy_worker/__init__.py create mode 100644 proxy_worker/__main__.py create mode 100644 proxy_worker/dispatcher.py create mode 100644 proxy_worker/logging.py create mode 100644 proxy_worker/protos/.gitignore create mode 100644 proxy_worker/protos/__init__.py create mode 100644 proxy_worker/protos/identity/__init__.py create mode 100644 proxy_worker/protos/shared/__init__.py create mode 100644 proxy_worker/start_worker.py create mode 100644 proxy_worker/utils/__init__.py create mode 100644 proxy_worker/utils/app_settings.py create mode 100644 proxy_worker/utils/common.py create mode 100644 proxy_worker/utils/constants.py create mode 100644 proxy_worker/utils/dependency.py create mode 100644 proxy_worker/version.py create mode 100644 python/proxyV4/worker.py diff --git a/proxy_worker/__init__.py b/proxy_worker/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/proxy_worker/__main__.py b/proxy_worker/__main__.py new file mode 100644 index 000000000..5141dd60a --- /dev/null +++ b/proxy_worker/__main__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from proxy_worker import start_worker + +if __name__ == '__main__': + start_worker.start() diff --git a/proxy_worker/dispatcher.py b/proxy_worker/dispatcher.py new file mode 100644 index 000000000..8eb194d66 --- /dev/null +++ b/proxy_worker/dispatcher.py @@ -0,0 +1,523 @@ +import asyncio +import concurrent.futures +import importlib.util +import logging +import os +import queue +import sys +import threading +import traceback +import typing +from asyncio import AbstractEventLoop +from dataclasses import dataclass +from typing import Optional + +import grpc +import azure_functions_worker + +from proxy_worker import protos +from proxy_worker.logging import ( + CONSOLE_LOG_PREFIX, + disable_console_logging, + enable_console_logging, + error_logger, + is_system_log_category, + logger, +) +from proxy_worker.utils.app_settings import get_app_setting, python_appsetting_state +from proxy_worker.utils.common import is_envvar_true +from proxy_worker.utils.constants import PYTHON_ENABLE_DEBUG_LOGGING, PYTHON_THREADPOOL_THREAD_COUNT +from proxy_worker.version import VERSION +from .utils.dependency import DependencyManager + +class ContextEnabledTask(asyncio.Task): + AZURE_INVOCATION_ID = '__azure_function_invocation_id__' + + def __init__(self, coro, loop, context=None): + super().__init__(coro, loop=loop, context=context) + + current_task = asyncio.current_task(loop) + if current_task is not None: + invocation_id = getattr( + current_task, self.AZURE_INVOCATION_ID, None) + if invocation_id is not None: + self.set_azure_invocation_id(invocation_id) + + def set_azure_invocation_id(self, invocation_id: str) -> None: + setattr(self, self.AZURE_INVOCATION_ID, invocation_id) + + +_invocation_id_local = threading.local() + + +def get_current_invocation_id() -> Optional[str]: + loop = asyncio._get_running_loop() + if loop is not None: + current_task = asyncio.current_task(loop) + if current_task is not None: + task_invocation_id = getattr(current_task, + ContextEnabledTask.AZURE_INVOCATION_ID, + None) + if task_invocation_id is not None: + return task_invocation_id + + return getattr(_invocation_id_local, 'invocation_id', None) + + +class AsyncLoggingHandler(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + # Since we disable console log after gRPC channel is initiated, + # we should redirect all the messages into dispatcher. + + # When dispatcher receives an exception, it should switch back + # to console logging. However, it is possible that + # __current_dispatcher__ is set to None as there are still messages + # buffered in this handler, not calling the emit yet. + msg = self.format(record) + try: + Dispatcher.current.on_logging(record, msg) + except RuntimeError as runtime_error: + # This will cause 'Dispatcher not found' failure. + # Logging such of an issue will cause infinite loop of gRPC logging + # To mitigate, we should suppress the 2nd level error logging here + # and use print function to report exception instead. + print(f'{CONSOLE_LOG_PREFIX} ERROR: {str(runtime_error)}', + file=sys.stderr, flush=True) + + +@dataclass +class WorkerRequest: + name: str + request: str + properties: Optional[dict[str, typing.Any]] = None + + +class DispatcherMeta(type): + __current_dispatcher__ = None + + @property + def current(cls): + disp = cls.__current_dispatcher__ + if disp is None: + raise RuntimeError('no currently running Dispatcher is found') + return disp + + +class Dispatcher(metaclass=DispatcherMeta): + _GRPC_STOP_RESPONSE = object() + + def __init__(self, loop: AbstractEventLoop, host: str, port: int, + worker_id: str, request_id: str, + grpc_connect_timeout: float, + grpc_max_msg_len: int = -1) -> None: + self._loop = loop + self._host = host + self._port = port + self._request_id = request_id + self._worker_id = worker_id + self._grpc_connect_timeout: float = grpc_connect_timeout + self._grpc_max_msg_len: int = grpc_max_msg_len + self._old_task_factory = None + + self._grpc_resp_queue: queue.Queue = queue.Queue() + self._grpc_connected_fut = loop.create_future() + self._grpc_thread: threading.Thread = threading.Thread( + name='grpc_local-thread', target=self.__poll_grpc) + + # TODO: Need to find a better place for these + self._function_data_cache_enabled = False + self._sync_call_tp: concurrent.futures.Executor = ( + self._create_sync_call_tp(self._get_sync_tp_max_workers())) + + def on_logging(self, record: logging.LogRecord, + formatted_msg: str) -> None: + if record.levelno >= logging.CRITICAL: + log_level = protos.RpcLog.Critical + elif record.levelno >= logging.ERROR: + log_level = protos.RpcLog.Error + elif record.levelno >= logging.WARNING: + log_level = protos.RpcLog.Warning + elif record.levelno >= logging.INFO: + log_level = protos.RpcLog.Information + elif record.levelno >= logging.DEBUG: + log_level = protos.RpcLog.Debug + else: + log_level = getattr(protos.RpcLog, 'None') + + if is_system_log_category(record.name): + log_category = protos.RpcLog.RpcLogCategory.Value('System') + else: # customers using logging will yield 'root' in record.name + log_category = protos.RpcLog.RpcLogCategory.Value('User') + + log = dict( + level=log_level, + message=formatted_msg, + category=record.name, + log_category=log_category + ) + + invocation_id = get_current_invocation_id() + if invocation_id is not None: + log['invocation_id'] = invocation_id + + self._grpc_resp_queue.put_nowait( + protos.StreamingMessage( + request_id=self.request_id, + rpc_log=protos.RpcLog(**log))) + + @property + def request_id(self) -> str: + return self._request_id + + @property + def worker_id(self) -> str: + return self._worker_id + + @classmethod + async def connect(cls, host: str, port: int, worker_id: str, + request_id: str, connect_timeout: float): + loop = asyncio.events.get_event_loop() + disp = cls(loop, host, port, worker_id, request_id, connect_timeout) + disp._grpc_thread.start() + await disp._grpc_connected_fut + logger.info('Successfully opened gRPC channel to %s:%s ', host, port) + return disp + + async def _initialize_grpc(self): + # Initialize gRPC-related attributes + self._grpc_resp_queue = queue.Queue() + self._grpc_connected_fut = self._loop.create_future() + self._grpc_thread = threading.Thread( + name='grpc_local-thread', target=self.__poll_grpc) + + # Start gRPC thread + self._grpc_thread.start() + + # Wait for gRPC connection to complete + await self._grpc_connected_fut + logger.info('Successfully opened gRPC channel to %s:%s', self._host, self._port) + + def __poll_grpc(self): + options = [] + if self._grpc_max_msg_len: + options.append(('grpc_local.max_receive_message_length', + self._grpc_max_msg_len)) + options.append(('grpc_local.max_send_message_length', + self._grpc_max_msg_len)) + + channel = grpc.insecure_channel( + f'{self._host}:{self._port}', options) + + try: + grpc.channel_ready_future(channel).result( + timeout=self._grpc_connect_timeout) + except Exception as ex: + self._loop.call_soon_threadsafe( + self._grpc_connected_fut.set_exception, ex) + return + else: + self._loop.call_soon_threadsafe( + self._grpc_connected_fut.set_result, True) + + stub = protos.FunctionRpcStub(channel) + + def gen(resp_queue): + while True: + msg = resp_queue.get() + if msg is self._GRPC_STOP_RESPONSE: + grpc_req_stream.cancel() + return + yield msg + + grpc_req_stream = stub.EventStream(gen(self._grpc_resp_queue)) + try: + for req in grpc_req_stream: + self._loop.call_soon_threadsafe( + self._loop.create_task, self._dispatch_grpc_request(req)) + except Exception as ex: + if ex is grpc_req_stream: + # Yes, this is how grpc_req_stream iterator exits. + return + error_logger.exception( + 'unhandled error in gRPC thread. Exception: {0}'.format( + ''.join(traceback.format_exception(ex)))) + raise + + async def _dispatch_grpc_request(self, request): + content_type = request.WhichOneof("content") + + match content_type: + case "worker_init_request": + request_handler = self._handle__worker_init_request + case "function_environment_reload_request": + request_handler = self._handle__function_environment_reload_request + case "functions_metadata_request": + request_handler = self._handle__functions_metadata_request + case "function_load_request": + request_handler = self._handle__function_load_request + case "worker_status_request": + request_handler = self._handle__worker_status_request + case "invocation_request": + request_handler = self._handle__invocation_request + case _: + # Don't crash on unknown messages. Log the error and return. + logger.error("Unknown StreamingMessage content type: %s", content_type) + return + + resp = await request_handler(request) + self._grpc_resp_queue.put_nowait(resp) + + async def dispatch_forever(self): # sourcery skip: swap-if-expression + if DispatcherMeta.__current_dispatcher__ is not None: + raise RuntimeError('there can be only one running dispatcher per ' + 'process') + + self._old_task_factory = self._loop.get_task_factory() + + DispatcherMeta.__current_dispatcher__ = self + try: + forever = self._loop.create_future() + + self._grpc_resp_queue.put_nowait( + protos.StreamingMessage( + request_id=self.request_id, + start_stream=protos.StartStream( + worker_id=self.worker_id))) + + # In Python 3.11+, constructing a task has an optional context + # parameter. Allow for this param to be passed to ContextEnabledTask + self._loop.set_task_factory( + lambda loop, coro, context=None: ContextEnabledTask( + coro, loop=loop, context=context)) + + # Detach console logging before enabling GRPC channel logging + logger.info('Detaching console logging.') + disable_console_logging() + + # Attach gRPC logging to the root logger. Since gRPC channel is + # established, should use it for system and user logs + logging_handler = AsyncLoggingHandler() + root_logger = logging.getLogger() + + log_level = logging.INFO if not is_envvar_true( + PYTHON_ENABLE_DEBUG_LOGGING) else logging.DEBUG + + root_logger.setLevel(log_level) + root_logger.addHandler(logging_handler) + logger.info('Switched to gRPC logging.') + logging_handler.flush() + + try: + await forever + finally: + logger.warning('Detaching gRPC logging due to exception.') + logging_handler.flush() + root_logger.removeHandler(logging_handler) + + # Reenable console logging when there's an exception + enable_console_logging() + logger.warning('Switched to console logging due to exception.') + finally: + DispatcherMeta.__current_dispatcher__ = None + + self._loop.set_task_factory(self._old_task_factory) + self.stop() + + def stop(self) -> None: + if self._grpc_thread is not None: + self._grpc_resp_queue.put_nowait(self._GRPC_STOP_RESPONSE) + self._grpc_thread.join() + self._grpc_thread = None + + self._stop_sync_call_tp() + + def _stop_sync_call_tp(self): + """Deallocate the current synchronous thread pool and assign + self._sync_call_tp to None. If the thread pool does not exist, + this will be a no op. + """ + if getattr(self, '_sync_call_tp', None): + self._sync_call_tp.shutdown() + self._sync_call_tp = None + + @staticmethod + def _create_sync_call_tp(max_worker: Optional[int]) -> concurrent.futures.Executor: + """Create a thread pool executor with max_worker. This is a wrapper + over ThreadPoolExecutor constructor. Consider calling this method after + _stop_sync_call_tp() to ensure only 1 synchronous thread pool is + running. + """ + return concurrent.futures.ThreadPoolExecutor( + max_workers=max_worker + ) + + @staticmethod + def _get_sync_tp_max_workers() -> typing.Optional[int]: + def tp_max_workers_validator(value: str) -> bool: + try: + int_value = int(value) + except ValueError: + logger.warning('%s must be an integer', + PYTHON_THREADPOOL_THREAD_COUNT) + return False + + if int_value < 1: + logger.warning( + '%s must be set to a value between 1 and sys.maxint. ' + 'Reverting to default value for max_workers', + PYTHON_THREADPOOL_THREAD_COUNT, + 1) + return False + return True + + max_workers = get_app_setting(setting=PYTHON_THREADPOOL_THREAD_COUNT, + validator=tp_max_workers_validator) + + # We can box the app setting as int for earlier python versions. + return int(max_workers) if max_workers else None + + @staticmethod + def reload_azure_functions_worker(): + try: + DependencyManager.reload_azure_google_namespace_from_worker_deps() + + customer_packages_path = sys.path[0] + potential_path = os.path.join(customer_packages_path, "azure_functions_worker", "__init__.py") + + if not os.path.exists(potential_path): + raise FileNotFoundError(f"ERROR: Expected module file not found at {potential_path}") + + if "azure_functions_worker" in sys.modules: + del sys.modules["azure_functions_worker"] + + # Create module spec with forced reloading + spec = importlib.util.spec_from_file_location("azure_functions_worker", potential_path) + + if spec is None: + raise ImportError(f"ERROR: Failed to create module spec for {potential_path}") + + if spec.loader is None: + raise ImportError(f"ERROR: spec.loader is None for {potential_path}") + + # Load module manually + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + # Force all references to use the new module + sys.modules["azure_functions_worker"] = module + globals()["azure_functions_worker"] = module + + logger.debug("V1 Programming model detected. Successfully loaded azure_functions_worker from:" + f" {module.__file__}") + except FileNotFoundError: + logger.debug("V2 Programming model detected. Skipping azure_functions_worker reload.") + + async def _handle__worker_init_request(self, request): + logger.info('Received WorkerInitRequest, ' + 'python version %s, ' + 'worker version %s, ' + 'request ID %s. ' + 'App Settings state: %s. ' + 'To enable debug level logging, please refer to ' + 'https://aka.ms/python-enable-debug-logging', + sys.version, + VERSION, + self.request_id, + python_appsetting_state()) + + init_request = WorkerRequest(name="WorkerInitRequest", + request=request, + properties={"protos": protos, + "host": self._host}) + if DependencyManager.should_load_cx_dependencies(): + DependencyManager.prioritize_customer_dependencies() + + self.reload_azure_functions_worker() + + init_response = await azure_functions_worker.worker_init_request(init_request) + logger.info("Finished WorkerInitRequest, request ID %s, worker id %s, ", + self.request_id, self.worker_id) + + return protos.StreamingMessage( + request_id=self.request_id, + worker_init_response=init_response) + + async def _handle__function_environment_reload_request(self, request): + logger.info('Received FunctionEnvironmentReloadRequest, ' + 'request ID: %s, ' + 'App Settings state: %s. ' + 'To enable debug level logging, please refer to ' + 'https://aka.ms/python-enable-debug-logging', + self.request_id, + python_appsetting_state()) + + func_env_reload_request = \ + request.function_environment_reload_request + directory = func_env_reload_request.function_app_directory + DependencyManager.reload_customer_libraries(directory) + + self.reload_azure_functions_worker() + + env_reload_request = WorkerRequest(name="FunctionEnvironmentReloadRequest", request=request, + properties={"protos": protos, + "host": self._host}) + env_reload_response = await azure_functions_worker.function_environment_reload_request(env_reload_request) + return protos.StreamingMessage( + request_id=self.request_id, + function_environment_reload_response=env_reload_response) + + async def _handle__worker_status_request(self, request): + # Logging is not necessary in this request since the response is used + # for host to judge scale decisions of out-of-proc languages. + # Having log here will reduce the responsiveness of the worker. + return protos.StreamingMessage( + request_id=request.request_id, + worker_status_response=protos.WorkerStatusResponse()) + + async def _handle__functions_metadata_request(self, request): + logger.info( + 'Received WorkerMetadataRequest, request ID %s, ' + 'worker id: %s', + self.request_id, self.worker_id) + + metadata_request = WorkerRequest(name="WorkerMetadataRequest", request=request) + metadata_response = await azure_functions_worker.functions_metadata_request(metadata_request) + + return protos.StreamingMessage( + request_id=request.request_id, + function_metadata_response=metadata_response) + + async def _handle__function_load_request(self, request): + func_request = request.function_load_request + function_id = func_request.function_id + function_metadata = func_request.metadata + function_name = function_metadata.name + + logger.info( + 'Received WorkerLoadRequest, request ID %s, function_id: %s,' + 'function_name: %s, worker_id: %s', + self.request_id, function_id, function_name, self.worker_id) + + load_request = WorkerRequest(name="FunctionsLoadRequest", request=request) + load_response = await azure_functions_worker.function_load_request(load_request) + + return protos.StreamingMessage( + request_id=self.request_id, + function_load_response=load_response) + + async def _handle__invocation_request(self, request): + invoc_request = request.invocation_request + invocation_id = invoc_request.invocation_id + function_id = invoc_request.function_id + + logger.info( + 'Received FunctionInvocationRequest, request ID %s, function_id: %s,' + 'invocation_id: %s, worker_id: %s', + self.request_id, function_id, invocation_id, self.worker_id) + + invocation_request = WorkerRequest(name="WorkerInvRequest", request=request, + properties={"threadpool": self._sync_call_tp}) + invocation_response = await azure_functions_worker.invocation_request(invocation_request) + return protos.StreamingMessage( + request_id=self.request_id, + invocation_response=invocation_response) diff --git a/proxy_worker/logging.py b/proxy_worker/logging.py new file mode 100644 index 000000000..7c723426b --- /dev/null +++ b/proxy_worker/logging.py @@ -0,0 +1,92 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import logging +import logging.handlers +import sys +from typing import Optional + +# Logging Prefixes +SYSTEM_LOG_PREFIX = "proxy_worker" +SDK_LOG_PREFIX = "azure.functions" +SYSTEM_ERROR_LOG_PREFIX = "azure_functions_worker_errors" +CONSOLE_LOG_PREFIX = "LanguageWorkerConsoleLog" + + +logger: logging.Logger = logging.getLogger(SYSTEM_LOG_PREFIX) +error_logger: logging.Logger = ( + logging.getLogger(SYSTEM_ERROR_LOG_PREFIX)) + +handler: Optional[logging.Handler] = None +error_handler: Optional[logging.Handler] = None + + +def setup(log_level, log_destination): + # Since handler and error_handler are moved to the global scope, + # before assigning to these handlers, we should define 'global' keyword + global handler + global error_handler + + if log_level == 'TRACE': + log_level = 'DEBUG' + + formatter = logging.Formatter(f'{CONSOLE_LOG_PREFIX}' + ' %(levelname)s: %(message)s') + + if log_destination is None: + # With no explicit log destination we do split logging, + # errors go into stderr, everything else -- to stdout. + error_handler = logging.StreamHandler(sys.stderr) + error_handler.setFormatter(formatter) + error_handler.setLevel(getattr(logging, log_level)) + + handler = logging.StreamHandler(sys.stdout) + + elif log_destination in ('stdout', 'stderr'): + handler = logging.StreamHandler(getattr(sys, log_destination)) + + elif log_destination == 'syslog': + handler = logging.handlers.SysLogHandler() + + else: + handler = logging.FileHandler(log_destination) + + if error_handler is None: + error_handler = handler + + handler.setFormatter(formatter) + handler.setLevel(getattr(logging, log_level)) + + logger.addHandler(handler) + logger.setLevel(getattr(logging, log_level)) + + error_logger.addHandler(error_handler) + error_logger.setLevel(getattr(logging, log_level)) + + +def disable_console_logging() -> None: + # We should only remove the sys.stdout stream, as error_logger is used for + # unexpected critical error logs handling. + if logger and handler: + handler.flush() + logger.removeHandler(handler) + + +def enable_console_logging() -> None: + if logger and handler: + logger.addHandler(handler) + + +def is_system_log_category(ctg: str) -> bool: + """Check if the logging namespace belongs to system logs. Category starts + with the following name will be treated as system logs. + 1. 'proxy_worker' (Worker Info) + 2. 'azure_functions_worker_errors' (Worker Error) + 3. 'azure.functions' (SDK) + + Expected behaviors for sytem logs and customer logs are listed below: + local_console customer_app_insight functions_kusto_table + system_log false false true + customer_log true true false + """ + return ctg.startswith(SYSTEM_LOG_PREFIX) or ctg.startswith(SDK_LOG_PREFIX) diff --git a/proxy_worker/protos/.gitignore b/proxy_worker/protos/.gitignore new file mode 100644 index 000000000..f43e6c214 --- /dev/null +++ b/proxy_worker/protos/.gitignore @@ -0,0 +1,3 @@ +/_src +*_pb2.py +*_pb2_grpc.py diff --git a/proxy_worker/protos/__init__.py b/proxy_worker/protos/__init__.py new file mode 100644 index 000000000..e9c4f2397 --- /dev/null +++ b/proxy_worker/protos/__init__.py @@ -0,0 +1,43 @@ +from .FunctionRpc_pb2_grpc import ( # NoQA + FunctionRpcStub, + FunctionRpcServicer, + add_FunctionRpcServicer_to_server) + +from .FunctionRpc_pb2 import ( # NoQA + StreamingMessage, + StartStream, + WorkerInitRequest, + WorkerInitResponse, + RpcFunctionMetadata, + FunctionLoadRequest, + FunctionLoadResponse, + FunctionEnvironmentReloadRequest, + FunctionEnvironmentReloadResponse, + InvocationRequest, + InvocationResponse, + WorkerHeartbeat, + WorkerStatusRequest, + WorkerStatusResponse, + BindingInfo, + StatusResult, + RpcException, + ParameterBinding, + TypedData, + RpcHttp, + RpcHttpCookie, + RpcLog, + RpcSharedMemory, + RpcDataType, + CloseSharedMemoryResourcesRequest, + CloseSharedMemoryResourcesResponse, + FunctionsMetadataRequest, + FunctionMetadataResponse, + WorkerMetadata, + RpcRetryOptions) + +from .shared.NullableTypes_pb2 import ( + NullableString, + NullableBool, + NullableDouble, + NullableTimestamp +) diff --git a/proxy_worker/protos/identity/__init__.py b/proxy_worker/protos/identity/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/proxy_worker/protos/shared/__init__.py b/proxy_worker/protos/shared/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/proxy_worker/start_worker.py b/proxy_worker/start_worker.py new file mode 100644 index 000000000..6a0aaca12 --- /dev/null +++ b/proxy_worker/start_worker.py @@ -0,0 +1,84 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +"""Main entrypoint.""" + +import argparse +import traceback + + +def parse_args(): + parser = argparse.ArgumentParser( + description='Python Azure Functions Worker') + parser.add_argument('--host', + help="host address") + parser.add_argument('--port', type=int, + help='port number') + parser.add_argument('--workerId', dest='worker_id', + help='id for the worker') + parser.add_argument('--requestId', dest='request_id', + help='id of the request') + parser.add_argument('--log-level', type=str, default='INFO', + choices=['TRACE', 'INFO', 'WARNING', 'ERROR'], + help="log level: 'TRACE', 'INFO', 'WARNING', " + "or 'ERROR'") + parser.add_argument('--log-to', type=str, default=None, + help='log destination: stdout, stderr, ' + 'syslog, or a file path') + parser.add_argument('--grpcMaxMessageLength', type=int, + dest='grpc_max_msg_len') + parser.add_argument('--functions-uri', dest='functions_uri', type=str, + help='URI with IP Address and Port used to' + ' connect to the Host via gRPC.') + parser.add_argument('--functions-request-id', dest='functions_request_id', + type=str, help='Request ID used for gRPC communication ' + 'with the Host.') + parser.add_argument('--functions-worker-id', + dest='functions_worker_id', type=str, + help='Worker ID assigned to this language worker.') + parser.add_argument('--functions-grpc-max-message-length', type=int, + dest='functions_grpc_max_msg_len', + help='Max grpc_local message length for Functions') + return parser.parse_args() + + +def start(): + from .utils.dependency import DependencyManager + DependencyManager.initialize() + DependencyManager.use_worker_dependencies() + + import asyncio + + from . import logging + from .logging import error_logger, logger + + args = parse_args() + logging.setup(log_level=args.log_level, log_destination=args.log_to) + + logger.info("Args: %s" , args) + logger.info('Starting Azure Functions Python Worker.') + logger.info('Worker ID: %s, Request ID: %s, Host Address: %s:%s', + args.worker_id, args.request_id, args.host, args.port) + + try: + return asyncio.run(start_async( + args.host, args.port, args.worker_id, args.request_id)) + except Exception as ex: + error_logger.exception( + 'unhandled error in functions worker: {0}'.format( + ''.join(traceback.format_exception(ex)))) + raise + + +async def start_async(host, port, worker_id, request_id): + from . import dispatcher + + # ToDo: Fix functions_grpc_max_msg_len. Needs to be parsed from args + disp = await dispatcher.Dispatcher.connect(host=host, port=port, + worker_id=worker_id, + request_id=request_id, + connect_timeout=5.0) + await disp.dispatch_forever() + + +if __name__ == '__main__': + start() diff --git a/proxy_worker/utils/__init__.py b/proxy_worker/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/proxy_worker/utils/app_settings.py b/proxy_worker/utils/app_settings.py new file mode 100644 index 000000000..ed8487ec3 --- /dev/null +++ b/proxy_worker/utils/app_settings.py @@ -0,0 +1,72 @@ +import os +from typing import Callable, Optional + +from .constants import ( + PYTHON_ENABLE_DEBUG_LOGGING, + PYTHON_ENABLE_INIT_INDEXING, + PYTHON_ENABLE_OPENTELEMETRY, + PYTHON_SCRIPT_FILE_NAME, + PYTHON_THREADPOOL_THREAD_COUNT, +) + + +def get_app_setting( + setting: str, + default_value: Optional[str] = None, + validator: Optional[Callable[[str], bool]] = None +) -> Optional[str]: + """Returns the application setting from environment variable. + + Parameters + ---------- + setting: str + The name of the application setting (e.g. FUNCTIONS_RUNTIME_VERSION) + + default_value: Optional[str] + The expected return value when the application setting is not found, + or the app setting does not pass the validator. + + validator: Optional[Callable[[str], bool]] + A function accepts the app setting value and should return True when + the app setting value is acceptable. + + Returns + ------- + Optional[str] + A string value that is set in the application setting + """ + app_setting_value = os.getenv(setting) + + # If an app setting is not configured, we return the default value + if app_setting_value is None: + return default_value + + # If there's no validator, we should return the app setting value directly + if validator is None: + return app_setting_value + + # If the app setting is set with a validator, + # On True, should return the app setting value + # On False, should return the default value + if validator(app_setting_value): + return app_setting_value + return default_value + + +def python_appsetting_state(): + current_vars = os.environ.copy() + python_specific_settings = \ + [ + PYTHON_THREADPOOL_THREAD_COUNT, + PYTHON_ENABLE_DEBUG_LOGGING, + PYTHON_SCRIPT_FILE_NAME, + PYTHON_ENABLE_INIT_INDEXING, + PYTHON_ENABLE_OPENTELEMETRY] + + app_setting_states = "".join( + f"{app_setting}: {current_vars[app_setting]} | " + for app_setting in python_specific_settings + if app_setting in current_vars + ) + + return app_setting_states diff --git a/proxy_worker/utils/common.py b/proxy_worker/utils/common.py new file mode 100644 index 000000000..2c212286d --- /dev/null +++ b/proxy_worker/utils/common.py @@ -0,0 +1,29 @@ +import os + + +def is_true_like(setting: str) -> bool: + if setting is None: + return False + + return setting.lower().strip() in {'1', 'true', 't', 'yes', 'y'} + + +def is_false_like(setting: str) -> bool: + if setting is None: + return False + + return setting.lower().strip() in {'0', 'false', 'f', 'no', 'n'} + + +def is_envvar_true(env_key: str) -> bool: + if os.getenv(env_key) is None: + return False + + return is_true_like(os.environ[env_key]) + + +def is_envvar_false(env_key: str) -> bool: + if os.getenv(env_key) is None: + return False + + return is_false_like(os.environ[env_key]) diff --git a/proxy_worker/utils/constants.py b/proxy_worker/utils/constants.py new file mode 100644 index 000000000..899b0433f --- /dev/null +++ b/proxy_worker/utils/constants.py @@ -0,0 +1,11 @@ +# App Setting constants +PYTHON_ENABLE_DEBUG_LOGGING = "PYTHON_ENABLE_DEBUG_LOGGING" +PYTHON_THREADPOOL_THREAD_COUNT = "PYTHON_THREADPOOL_THREAD_COUNT" +PYTHON_SCRIPT_FILE_NAME = "PYTHON_SCRIPT_FILE_NAME" +PYTHON_ENABLE_INIT_INDEXING = "PYTHON_ENABLE_INIT_INDEXING" +PYTHON_ENABLE_OPENTELEMETRY= "PYTHON_ENABLE_OPENTELEMETRY" +PYTHON_ISOLATE_WORKER_DEPENDENCIES = "PYTHON_ISOLATE_WORKER_DEPENDENCIES" + + +CONTAINER_NAME = "CONTAINER_NAME" +AZURE_WEBJOBS_SCRIPT_ROOT = "AzureWebJobsScriptRoot" diff --git a/proxy_worker/utils/dependency.py b/proxy_worker/utils/dependency.py new file mode 100644 index 000000000..4331deaf9 --- /dev/null +++ b/proxy_worker/utils/dependency.py @@ -0,0 +1,370 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import importlib.util +import inspect +import os +import re +import sys +from types import ModuleType +from typing import List, Optional + +from .common import is_envvar_true, is_true_like +from .constants import AZURE_WEBJOBS_SCRIPT_ROOT, CONTAINER_NAME, PYTHON_ISOLATE_WORKER_DEPENDENCIES +from ..logging import logger + + +class DependencyManager: + """The dependency manager controls the Python packages source, preventing + worker packages interfer customer's code. + + It has two mode, in worker mode, the Python packages are loaded from worker + path, (e.g. workers/python///). In customer mode, + the packages are loaded from customer's .python_packages/ folder or from + their virtual environment. + + Azure Functions has three different set of sys.path ordering, + + Linux Consumption sys.path: [ + "/tmp/functions\\standby\\wwwroot", # Placeholder folder + "/home/site/wwwroot/.python_packages/lib/site-packages", # CX's deps + "/azure-functions-host/workers/python/3.13/LINUX/X64", # Worker's deps + "/home/site/wwwroot" # CX's Working Directory + ] + + Linux Dedicated/Premium sys.path: [ + "/home/site/wwwroot", # CX's Working Directory + "/home/site/wwwroot/.python_packages/lib/site-packages", # CX's deps + "/azure-functions-host/workers/python/3.13/LINUX/X64", # Worker's deps + ] + + Core Tools sys.path: [ + "%appdata%\\azure-functions-core-tools\\bin\\workers\\" + "python\\3.13\\WINDOWS\\X64", # Worker's deps + "C:\\Users\\user\\Project\\.venv311\\lib\\site-packages", # CX's deps + "C:\\Users\\user\\Project", # CX's Working Directory + ] + + When we first start up the Python worker, we should only loaded from + worker's deps and create module namespace (e.g. google.protobuf variable). + + Once the worker receives worker init request, we clear out the sys.path, + worker sys.modules cache and sys.path_import_cache so the libraries + will only get loaded from CX's deps path. + """ + + cx_deps_path: str = '' + cx_working_dir: str = '' + worker_deps_path: str = '' + + @classmethod + def initialize(cls): + cls.cx_deps_path = cls._get_cx_deps_path() + cls.cx_working_dir = cls._get_cx_working_dir() + cls.worker_deps_path = cls._get_worker_deps_path() + + @classmethod + def is_in_linux_consumption(cls): + return CONTAINER_NAME in os.environ + + @classmethod + def should_load_cx_dependencies(cls): + """ + Customer dependencies should be loaded when + 1) App is a dedicated app + 2) App is linux consumption but not in placeholder mode. + This can happen when the worker restarts for any reason + (OOM, timeouts etc) and env reload request is not called. + """ + return not (DependencyManager.is_in_linux_consumption() + and is_envvar_true("WEBSITE_PLACEHOLDER_MODE")) + + @classmethod + def use_worker_dependencies(cls): + """Switch the sys.path and ensure the worker imports are loaded from + Worker's dependenciess. + + This will not affect already imported namespaces, but will clear out + the module cache and ensure the upcoming modules are loaded from + worker's dependency path. + """ + + # The following log line will not show up in core tools but should + # work in kusto since core tools only collects gRPC logs. This function + # is executed even before the gRPC logging channel is ready. + logger.info('Applying use_worker_dependencies:' + ' worker_dependencies: %s,' + ' customer_dependencies: %s,' + ' working_directory: %s', cls.worker_deps_path, + cls.cx_deps_path, cls.cx_working_dir) + + cls._remove_from_sys_path(cls.cx_deps_path) + cls._remove_from_sys_path(cls.cx_working_dir) + cls._add_to_sys_path(cls.worker_deps_path, True) + logger.info('Start using worker dependencies %s. Sys.path: %s', cls.worker_deps_path, sys.path) + + @classmethod + def reload_customer_libraries(cls, cx_working_dir: str): + """Reload azure and google namespace, this including any modules in + this namespace, such as azure-functions, grpcio, grpcio-tools etc. + + Depends on the PYTHON_ISOLATE_WORKER_DEPENDENCIES, the actual behavior + differs. + + This is called only when placeholder mode is true. In the case of a + worker restart, this will not be called. + + Parameters + ---------- + cx_working_dir: str + The path which contains customer's project file (e.g. wwwroot). + """ + isolate_dependencies_setting = os.getenv(PYTHON_ISOLATE_WORKER_DEPENDENCIES) + if isolate_dependencies_setting is None: + isolate_dependencies = True + else: + isolate_dependencies = is_true_like(isolate_dependencies_setting) + + if isolate_dependencies: + cls.prioritize_customer_dependencies(cx_working_dir) + else: + cls.reload_azure_google_namespace_from_worker_deps() + + + @classmethod + def prioritize_customer_dependencies(cls, cx_working_dir=None): + """Switch the sys.path and ensure the customer's code import are loaded + from CX's deppendencies. + + This will not affect already imported namespaces, but will clear out + the module cache and ensure the upcoming modules are loaded from + customer's dependency path. + + As for Linux Consumption, this will only remove worker_deps_path, + but the customer's path will be loaded in function_environment_reload. + + The search order of a module name in customer's paths is: + 1. cx_deps_path + 2. worker_deps_path + 3. cx_working_dir + """ + # Try to get the latest customer's working directory + # cx_working_dir => cls.cx_working_dir => AzureWebJobsScriptRoot + working_directory: str = '' + if cx_working_dir: + working_directory: str = os.path.abspath(cx_working_dir) + if not working_directory: + working_directory = cls.cx_working_dir + if not working_directory: + working_directory = os.getenv(AZURE_WEBJOBS_SCRIPT_ROOT, '') + + # Try to get the latest customer's dependency path + cx_deps_path: str = cls._get_cx_deps_path() + + if not cx_deps_path: + cx_deps_path = cls.cx_deps_path + + logger.info( + 'Applying prioritize_customer_dependencies: ' + 'worker_dependencies_path: %s, customer_dependencies_path: %s, ' + 'working_directory: %s, Linux Consumption: %s, Placeholder: %s, sys.path: %s', + cls.worker_deps_path, cx_deps_path, working_directory, + DependencyManager.is_in_linux_consumption(), + is_envvar_true("WEBSITE_PLACEHOLDER_MODE"), sys.path) + + cls._remove_from_sys_path(cls.worker_deps_path) + cls._add_to_sys_path(cls.cx_deps_path, True) + cls._add_to_sys_path(working_directory, False) + cls._add_to_sys_path(cls.worker_deps_path, False) + + logger.info(f'Finished prioritize_customer_dependencies: {sys.path}') + + @classmethod + def reload_azure_google_namespace_from_worker_deps(cls): + """This is the old implementation of reloading azure and google + namespace in Python worker directory. It is not actually re-importing + the module but only reloads the module scripts from the worker path. + + It is not doing what it is intended, but due to it is already released + on Linux Consumption production, we don't want to introduce regression + on existing customers. + + Only intended to be used in Linux Consumption scenario. + """ + # Reload package namespaces for customer's libraries + packages_to_reload = [ 'azure', 'google'] + packages_reloaded = [] + for p in packages_to_reload: + try: + importlib.reload(sys.modules[p]) + packages_reloaded.append(p) + except Exception as ex: + logger.warning('Unable to reload %s: \n%s', p, ex) + + logger.info(f'Reloaded modules: {",".join(packages_reloaded)}') + + # Reload azure.functions to give user package precedence + try: + importlib.reload(sys.modules['azure.functions']) + logger.info('Reloaded azure.functions module now at %s', + inspect.getfile(sys.modules['azure.functions'])) + except Exception as ex: + logger.warning( + 'Unable to reload azure.functions. Using default. ' + 'Exception:\n%s', ex) + + @classmethod + def _add_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) + """ + if 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. + No action if the path does not exist in sys.path. + + Parameters + ---------- + path: str + The path to be removed from sys.path. + If the path is an empty string, no action will be taken. + """ + if path and path in sys.path: + # Remove all occurances in sys.path + sys.path = list(filter(lambda p: p != path, sys.path)) + + # In case if any part of worker initialization do sys.path.pop() + # Always do a cache clear in path importer and sys.modules + cls._clear_path_importer_cache_and_modules(path) + + @classmethod + def _clear_path_importer_cache_and_modules(cls, path: str): + """Removes path from sys.path_importer_cache and clear related + sys.modules cache. No action if the path is empty or no entries + in sys.path_importer_cache or sys.modules. + + Parameters + ---------- + path: str + The path to be removed from sys.path_importer_cache. All related + modules will be cleared out from sys.modules cache. + If the path is an empty string, no action will be taken. + """ + if path and path in sys.path_importer_cache: + sys.path_importer_cache.pop(path) + + if path: + cls._remove_module_cache(path) + + @staticmethod + def _get_cx_deps_path() -> str: + """Get the directory storing the customer's third-party libraries. + + Returns + ------- + str + Core Tools: path to customer's site packages + Linux Dedicated/Premium: path to customer's site packages + Linux Consumption: empty string + """ + prefix: Optional[str] = os.getenv(AZURE_WEBJOBS_SCRIPT_ROOT) + cx_paths: List[str] = [ + p for p in sys.path + if prefix and p.startswith(prefix) and ('site-packages' in p) + ] + # Return first or default of customer path + return (cx_paths or [''])[0] + + @staticmethod + def _get_cx_working_dir() -> str: + """Get the customer's working directory. + + Returns + ------- + str + Core Tools: AzureWebJobsScriptRoot env variable + Linux Dedicated/Premium: AzureWebJobsScriptRoot env variable + Linux Consumption: empty string + """ + return os.getenv(AZURE_WEBJOBS_SCRIPT_ROOT, '') + + @staticmethod + def _get_worker_deps_path() -> str: + """Get the worker dependency sys.path. This will always available + even in all skus. + + Returns + ------- + str + The worker packages path + """ + # 1. Try to parse the absolute path python/3.13/LINUX/X64 in sys.path + r = re.compile(r'.*python(\/|\\)\d+\.\d+(\/|\\)(WINDOWS|LINUX|OSX).*') + worker_deps_paths: List[str] = [p for p in sys.path if r.match(p)] + if worker_deps_paths: + return worker_deps_paths[0] + + # 2. If it fails to find one, try to find one from the parent path + # This is used for handling the CI/localdev environment + return os.path.abspath( + os.path.join(os.path.dirname(__file__), '..', '..') + ) + + @staticmethod + def _remove_module_cache(path: str): + """Remove module cache if the module is imported from specific path. + This will not impact builtin modules + + Parameters + ---------- + path: str + The module cache to be removed if it is imported from this path. + """ + if not path: + return + + not_builtin = set(sys.modules.keys()) - set(sys.builtin_module_names) + + # Don't reload proxy_worker + to_be_cleared_from_cache = set([ + module_name for module_name in not_builtin + if not module_name.startswith('proxy_worker') + ]) + + for module_name in to_be_cleared_from_cache: + module = sys.modules.get(module_name) + if not isinstance(module, ModuleType): + continue + + # Module path can be actual file path or a pure namespace path. + # Both of these has the module path placed in __path__ property + # The property .__path__ can be None or does not exist in module + try: + module_paths = set(getattr(module, '__path__', None) or []) + if hasattr(module, '__file__') and module.__file__: + module_paths.add(module.__file__) + + if any([p for p in module_paths if p.startswith(path)]): + sys.modules.pop(module_name) + except Exception as e: + logger.warning( + 'Attempt to remove module cache for %s but failed with ' + '%s. Using the original module cache.', + module_name, e) diff --git a/proxy_worker/version.py b/proxy_worker/version.py new file mode 100644 index 000000000..a56028d71 --- /dev/null +++ b/proxy_worker/version.py @@ -0,0 +1 @@ +VERSION="1.0.0" \ No newline at end of file diff --git a/python/proxyV4/worker.py b/python/proxyV4/worker.py new file mode 100644 index 000000000..16a837dae --- /dev/null +++ b/python/proxyV4/worker.py @@ -0,0 +1,64 @@ +import os +import pathlib +import sys + + +PKGS_PATH = "/home/site/wwwroot/.python_packages" +PKGS = "lib/site-packages" + +# Azure environment variables +AZURE_WEBSITE_INSTANCE_ID = "WEBSITE_INSTANCE_ID" +AZURE_CONTAINER_NAME = "CONTAINER_NAME" +AZURE_WEBJOBS_SCRIPT_ROOT = "AzureWebJobsScriptRoot" + + + +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) + + +def validate_python_version(): + minor_version = sys.version_info[1] + if not (13 <= minor_version < 14): + raise RuntimeError(f'Unsupported Python version: 3.{minor_version}') + + +def determine_user_pkg_paths(): + """This finds the user packages when function apps are running on the cloud + User packages are defined in: + /home/site/wwwroot/.python_packages/lib/site-packages + """ + validate_python_version() + usr_packages_path = [os.path.join(PKGS_PATH, PKGS)] + return usr_packages_path + + +def add_script_root_to_sys_path(): + """Append function project root to module finding sys.path""" + functions_script_root = os.getenv(AZURE_WEBJOBS_SCRIPT_ROOT) + if functions_script_root is not None: + sys.path.append(functions_script_root) + + +if __name__ == '__main__': + # worker.py lives in the same directory as proxy_worker + func_worker_dir = str(pathlib.Path(__file__).absolute()) + env = os.environ + + # Setting up python path for all environments to prioritize + # third-party user packages over worker packages in PYTHONPATH + user_pkg_paths = determine_user_pkg_paths() + joined_pkg_paths = os.pathsep.join(user_pkg_paths) + env['PYTHONPATH'] = f'{joined_pkg_paths}:{func_worker_dir}' + + if is_azure_environment(): + os.execve(sys.executable, + [sys.executable, '-m', 'proxy_worker'] + + sys.argv[1:], + os.environ) + else: + add_script_root_to_sys_path() + from proxy_worker import start_worker + start_worker.start() From 4d3b89230bd2bc7b411840d846d0dc4548432687 Mon Sep 17 00:00:00 2001 From: Gavin Aguiar Date: Thu, 6 Feb 2025 10:55:45 -0600 Subject: [PATCH 02/42] Updated worker config to include 3.13 --- python/prodV4/worker.config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/prodV4/worker.config.json b/python/prodV4/worker.config.json index 548822af9..98f1e56db 100644 --- a/python/prodV4/worker.config.json +++ b/python/prodV4/worker.config.json @@ -3,7 +3,7 @@ "language":"python", "defaultRuntimeVersion":"3.11", "supportedOperatingSystems":["LINUX", "OSX", "WINDOWS"], - "supportedRuntimeVersions":["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"], + "supportedRuntimeVersions":["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"], "supportedArchitectures":["X64", "X86", "Arm64"], "extensions":[".py"], "defaultExecutablePath":"python", From a6c80f120c7654d990d0725a6506b495badecb7d Mon Sep 17 00:00:00 2001 From: Gavin Aguiar Date: Fri, 7 Feb 2025 13:55:24 -0600 Subject: [PATCH 03/42] Updated test_setup --- tests/test_setup.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/test_setup.py b/tests/test_setup.py index fd6f0044e..f08bfc29c 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -28,6 +28,7 @@ import urllib.request import zipfile from distutils import dir_util +from xml.dom import WRONG_DOCUMENT_ERR from invoke import task @@ -38,6 +39,7 @@ WEBHOST_GITHUB_API = "https://api.github.com/repos/Azure/azure-functions-host" WEBHOST_GIT_REPO = "https://github.com/Azure/azure-functions-host/archive" WEBHOST_TAG_PREFIX = "v4." +WORKER_DIR = "azure_functions_worker" if sys.version_info.minor < 13 else "proxy_worker" def get_webhost_version() -> str: @@ -129,10 +131,10 @@ def compile_webhost(webhost_dir): def gen_grpc(): - proto_root_dir = ROOT_DIR / "azure_functions_worker" / "protos" + proto_root_dir = ROOT_DIR / WORKER_DIR / "protos" proto_src_dir = proto_root_dir / "_src" / "src" / "proto" staging_root_dir = BUILD_DIR / "protos" - staging_dir = staging_root_dir / "azure_functions_worker" / "protos" + staging_dir = staging_root_dir / WORKER_DIR / "protos" built_protos_dir = BUILD_DIR / "built_protos" if os.path.exists(BUILD_DIR): @@ -154,12 +156,12 @@ def gen_grpc(): "-m", "grpc_tools.protoc", "-I", - os.sep.join(("azure_functions_worker", "protos")), + os.sep.join((WORKER_DIR, "protos")), "--python_out", str(built_protos_dir), "--grpc_python_out", str(built_protos_dir), - os.sep.join(("azure_functions_worker", "protos", proto)), + os.sep.join((WORKER_DIR, "protos", proto)), ], check=True, stdout=sys.stdout, @@ -192,7 +194,7 @@ def make_absolute_imports(compiled_files): # from azure_functions_worker.protos import xxx_pb2 as.. p1 = re.sub( r"\nimport (.*?_pb2)", - r"\nfrom azure_functions_worker.protos import \g<1>", + fr"\nfrom {WORKER_DIR}.protos import \g<1>", content, ) # Convert lines of the form: @@ -200,7 +202,7 @@ def make_absolute_imports(compiled_files): # from azure_functions_worker.protos.identity import xxx_pb2.. p2 = re.sub( r"from ([a-z]*) (import.*_pb2)", - r"from azure_functions_worker.protos.\g<1> \g<2>", + fr"from {WORKER_DIR}.protos.\g<1> \g<2>", p1, ) f.write(p2) From 5343e0ef3fc9ce77de0d7b6cba4c639c13c66222 Mon Sep 17 00:00:00 2001 From: Gavin Aguiar Date: Fri, 7 Feb 2025 16:15:26 -0600 Subject: [PATCH 04/42] Updated worker.py --- python/proxyV4/worker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/proxyV4/worker.py b/python/proxyV4/worker.py index 16a837dae..863381d9e 100644 --- a/python/proxyV4/worker.py +++ b/python/proxyV4/worker.py @@ -57,7 +57,7 @@ def add_script_root_to_sys_path(): os.execve(sys.executable, [sys.executable, '-m', 'proxy_worker'] + sys.argv[1:], - os.environ) + env) else: add_script_root_to_sys_path() from proxy_worker import start_worker From d1390d5175122782c215730c06a7f913a9cc46ce Mon Sep 17 00:00:00 2001 From: Gavin Aguiar Date: Tue, 11 Feb 2025 11:55:54 -0600 Subject: [PATCH 05/42] Updated dispatcher --- proxy_worker/dispatcher.py | 62 +++++++++++--------------------------- python/proxyV4/worker.py | 3 +- 2 files changed, 19 insertions(+), 46 deletions(-) diff --git a/proxy_worker/dispatcher.py b/proxy_worker/dispatcher.py index 8eb194d66..74ea88f37 100644 --- a/proxy_worker/dispatcher.py +++ b/proxy_worker/dispatcher.py @@ -2,7 +2,6 @@ import concurrent.futures import importlib.util import logging -import os import queue import sys import threading @@ -13,7 +12,6 @@ from typing import Optional import grpc -import azure_functions_worker from proxy_worker import protos from proxy_worker.logging import ( @@ -30,6 +28,9 @@ from proxy_worker.version import VERSION from .utils.dependency import DependencyManager +# Library worker import reloaded in init and reload request +library_worker = None + class ContextEnabledTask(asyncio.Task): AZURE_INVOCATION_ID = '__azure_function_invocation_id__' @@ -103,6 +104,7 @@ def current(cls): return disp + class Dispatcher(metaclass=DispatcherMeta): _GRPC_STOP_RESPONSE = object() @@ -376,42 +378,6 @@ def tp_max_workers_validator(value: str) -> bool: # We can box the app setting as int for earlier python versions. return int(max_workers) if max_workers else None - @staticmethod - def reload_azure_functions_worker(): - try: - DependencyManager.reload_azure_google_namespace_from_worker_deps() - - customer_packages_path = sys.path[0] - potential_path = os.path.join(customer_packages_path, "azure_functions_worker", "__init__.py") - - if not os.path.exists(potential_path): - raise FileNotFoundError(f"ERROR: Expected module file not found at {potential_path}") - - if "azure_functions_worker" in sys.modules: - del sys.modules["azure_functions_worker"] - - # Create module spec with forced reloading - spec = importlib.util.spec_from_file_location("azure_functions_worker", potential_path) - - if spec is None: - raise ImportError(f"ERROR: Failed to create module spec for {potential_path}") - - if spec.loader is None: - raise ImportError(f"ERROR: spec.loader is None for {potential_path}") - - # Load module manually - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - - # Force all references to use the new module - sys.modules["azure_functions_worker"] = module - globals()["azure_functions_worker"] = module - - logger.debug("V1 Programming model detected. Successfully loaded azure_functions_worker from:" - f" {module.__file__}") - except FileNotFoundError: - logger.debug("V2 Programming model detected. Skipping azure_functions_worker reload.") - async def _handle__worker_init_request(self, request): logger.info('Received WorkerInitRequest, ' 'python version %s, ' @@ -432,9 +398,12 @@ async def _handle__worker_init_request(self, request): if DependencyManager.should_load_cx_dependencies(): DependencyManager.prioritize_customer_dependencies() - self.reload_azure_functions_worker() + import azure_functions_worker as worker + global library_worker + importlib.reload(worker) + library_worker = worker - init_response = await azure_functions_worker.worker_init_request(init_request) + init_response = await library_worker.worker_init_request(init_request) logger.info("Finished WorkerInitRequest, request ID %s, worker id %s, ", self.request_id, self.worker_id) @@ -456,12 +425,15 @@ async def _handle__function_environment_reload_request(self, request): directory = func_env_reload_request.function_app_directory DependencyManager.reload_customer_libraries(directory) - self.reload_azure_functions_worker() + import azure_functions_worker as worker + global library_worker + importlib.reload(worker) + library_worker = worker env_reload_request = WorkerRequest(name="FunctionEnvironmentReloadRequest", request=request, properties={"protos": protos, "host": self._host}) - env_reload_response = await azure_functions_worker.function_environment_reload_request(env_reload_request) + env_reload_response = await library_worker.function_environment_reload_request(env_reload_request) return protos.StreamingMessage( request_id=self.request_id, function_environment_reload_response=env_reload_response) @@ -481,7 +453,7 @@ async def _handle__functions_metadata_request(self, request): self.request_id, self.worker_id) metadata_request = WorkerRequest(name="WorkerMetadataRequest", request=request) - metadata_response = await azure_functions_worker.functions_metadata_request(metadata_request) + metadata_response = await library_worker.functions_metadata_request(metadata_request) return protos.StreamingMessage( request_id=request.request_id, @@ -499,7 +471,7 @@ async def _handle__function_load_request(self, request): self.request_id, function_id, function_name, self.worker_id) load_request = WorkerRequest(name="FunctionsLoadRequest", request=request) - load_response = await azure_functions_worker.function_load_request(load_request) + load_response = await library_worker.function_load_request(load_request) return protos.StreamingMessage( request_id=self.request_id, @@ -517,7 +489,7 @@ async def _handle__invocation_request(self, request): invocation_request = WorkerRequest(name="WorkerInvRequest", request=request, properties={"threadpool": self._sync_call_tp}) - invocation_response = await azure_functions_worker.invocation_request(invocation_request) + invocation_response = await library_worker.invocation_request(invocation_request) return protos.StreamingMessage( request_id=self.request_id, invocation_response=invocation_response) diff --git a/python/proxyV4/worker.py b/python/proxyV4/worker.py index 863381d9e..c9e529fad 100644 --- a/python/proxyV4/worker.py +++ b/python/proxyV4/worker.py @@ -30,7 +30,6 @@ def determine_user_pkg_paths(): User packages are defined in: /home/site/wwwroot/.python_packages/lib/site-packages """ - validate_python_version() usr_packages_path = [os.path.join(PKGS_PATH, PKGS)] return usr_packages_path @@ -44,6 +43,8 @@ def add_script_root_to_sys_path(): if __name__ == '__main__': # worker.py lives in the same directory as proxy_worker + validate_python_version() + func_worker_dir = str(pathlib.Path(__file__).absolute()) env = os.environ From bcad8155d8f013d6839147a28932b6cbd9406ebe Mon Sep 17 00:00:00 2001 From: Gavin Aguiar Date: Tue, 11 Feb 2025 13:55:06 -0600 Subject: [PATCH 06/42] Updated syspath in worker.py --- python/proxyV4/worker.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/python/proxyV4/worker.py b/python/proxyV4/worker.py index c9e529fad..c2b96db91 100644 --- a/python/proxyV4/worker.py +++ b/python/proxyV4/worker.py @@ -54,6 +54,10 @@ def add_script_root_to_sys_path(): joined_pkg_paths = os.pathsep.join(user_pkg_paths) env['PYTHONPATH'] = f'{joined_pkg_paths}:{func_worker_dir}' + project_root = os.path.abspath(os.path.dirname(__file__)) + if project_root not in sys.path: + sys.path.append(project_root) + if is_azure_environment(): os.execve(sys.executable, [sys.executable, '-m', 'proxy_worker'] From f720195ccec4b4e3cdaf50fa4f691e4ab1a693a1 Mon Sep 17 00:00:00 2001 From: Gavin Aguiar Date: Wed, 12 Feb 2025 11:27:21 -0600 Subject: [PATCH 07/42] Updated path in worker.py --- python/proxyV4/worker.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/python/proxyV4/worker.py b/python/proxyV4/worker.py index c2b96db91..dda80d520 100644 --- a/python/proxyV4/worker.py +++ b/python/proxyV4/worker.py @@ -42,10 +42,10 @@ def add_script_root_to_sys_path(): if __name__ == '__main__': - # worker.py lives in the same directory as proxy_worker - validate_python_version() + # worker.py lives in the same directory as azure_functions_worker - func_worker_dir = str(pathlib.Path(__file__).absolute()) + validate_python_version() + func_worker_dir = str(pathlib.Path(__file__).absolute().parent) env = os.environ # Setting up python path for all environments to prioritize From f7f47956d607237973c9c60bd22f53f2a569479c Mon Sep 17 00:00:00 2001 From: Gavin Aguiar Date: Fri, 14 Feb 2025 11:39:36 -0600 Subject: [PATCH 08/42] Updated worker.py --- python/proxyV4/worker.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/python/proxyV4/worker.py b/python/proxyV4/worker.py index dda80d520..dce5d51e6 100644 --- a/python/proxyV4/worker.py +++ b/python/proxyV4/worker.py @@ -42,8 +42,6 @@ def add_script_root_to_sys_path(): if __name__ == '__main__': - # worker.py lives in the same directory as azure_functions_worker - validate_python_version() func_worker_dir = str(pathlib.Path(__file__).absolute().parent) env = os.environ From 43d29a37e018a40624b759f2c3c45b09f69ffc12 Mon Sep 17 00:00:00 2001 From: Gavin Aguiar Date: Fri, 14 Feb 2025 12:24:58 -0600 Subject: [PATCH 09/42] Removed reload in dispatcher --- proxy_worker/dispatcher.py | 1 - 1 file changed, 1 deletion(-) diff --git a/proxy_worker/dispatcher.py b/proxy_worker/dispatcher.py index 74ea88f37..dc5776205 100644 --- a/proxy_worker/dispatcher.py +++ b/proxy_worker/dispatcher.py @@ -400,7 +400,6 @@ async def _handle__worker_init_request(self, request): import azure_functions_worker as worker global library_worker - importlib.reload(worker) library_worker = worker init_response = await library_worker.worker_init_request(init_request) From bcdf8191a13c75852fb4fb98b4d7701669f924db Mon Sep 17 00:00:00 2001 From: Gavin Aguiar Date: Tue, 18 Feb 2025 12:13:08 -0600 Subject: [PATCH 10/42] Updating v1 library worker name --- proxy_worker/dispatcher.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/proxy_worker/dispatcher.py b/proxy_worker/dispatcher.py index dc5776205..c7a90c896 100644 --- a/proxy_worker/dispatcher.py +++ b/proxy_worker/dispatcher.py @@ -398,7 +398,11 @@ async def _handle__worker_init_request(self, request): if DependencyManager.should_load_cx_dependencies(): DependencyManager.prioritize_customer_dependencies() - import azure_functions_worker as worker + try: + import azure_functions_worker_v1 as worker + except ImportError: + import azure_functions_worker as worker + global library_worker library_worker = worker @@ -424,9 +428,12 @@ async def _handle__function_environment_reload_request(self, request): directory = func_env_reload_request.function_app_directory DependencyManager.reload_customer_libraries(directory) - import azure_functions_worker as worker + try: + import azure_functions_worker_v1 as worker + except ImportError: + import azure_functions_worker as worker + global library_worker - importlib.reload(worker) library_worker = worker env_reload_request = WorkerRequest(name="FunctionEnvironmentReloadRequest", request=request, From bc4a5202f56933ff8cefeedd27b5848a51796995 Mon Sep 17 00:00:00 2001 From: Gavin Aguiar Date: Tue, 18 Feb 2025 15:09:30 -0600 Subject: [PATCH 11/42] Added dispatcher logs --- proxy_worker/dispatcher.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/proxy_worker/dispatcher.py b/proxy_worker/dispatcher.py index c7a90c896..7c2151ccb 100644 --- a/proxy_worker/dispatcher.py +++ b/proxy_worker/dispatcher.py @@ -399,12 +399,21 @@ async def _handle__worker_init_request(self, request): DependencyManager.prioritize_customer_dependencies() try: + logger.info("Trying to import v1 worker") import azure_functions_worker_v1 as worker + logger.info(f"V1 worker Import succeeded: {worker.__file__}") except ImportError: + logger.info("Trying to import v2 worker") import azure_functions_worker as worker + logger.info(f"V2 worker Import succeeded: {worker.__file__}") + except Exception as e: + logger.info(f"Some other ex: {e}") + logger.info("Updating globals") global library_worker library_worker = worker + logger.info(f"Done Updating globals: {worker.__file__}") + init_response = await library_worker.worker_init_request(init_request) logger.info("Finished WorkerInitRequest, request ID %s, worker id %s, ", From 7c7cc36fbf58b207db7d1e919f8470f725b6561f Mon Sep 17 00:00:00 2001 From: Gavin Aguiar Date: Tue, 18 Feb 2025 15:50:31 -0600 Subject: [PATCH 12/42] Added dispatcher try/catch logs --- proxy_worker/dispatcher.py | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/proxy_worker/dispatcher.py b/proxy_worker/dispatcher.py index 7c2151ccb..09363d7fc 100644 --- a/proxy_worker/dispatcher.py +++ b/proxy_worker/dispatcher.py @@ -391,31 +391,34 @@ async def _handle__worker_init_request(self, request): self.request_id, python_appsetting_state()) - init_request = WorkerRequest(name="WorkerInitRequest", - request=request, - properties={"protos": protos, - "host": self._host}) + if DependencyManager.should_load_cx_dependencies(): DependencyManager.prioritize_customer_dependencies() + global library_worker try: logger.info("Trying to import v1 worker") - import azure_functions_worker_v1 as worker - logger.info(f"V1 worker Import succeeded: {worker.__file__}") + import azure_functions_worker_v1 + library_worker = azure_functions_worker_v1 + logger.info(f"V1 worker Import succeeded: {library_worker.__file__}") except ImportError: logger.info("Trying to import v2 worker") - import azure_functions_worker as worker - logger.info(f"V2 worker Import succeeded: {worker.__file__}") + import azure_functions_worker + library_worker = azure_functions_worker + logger.info(f"V2 worker Import succeeded: {library_worker.__file__}") except Exception as e: logger.info(f"Some other ex: {e}") - logger.info("Updating globals") - global library_worker - library_worker = worker - logger.info(f"Done Updating globals: {worker.__file__}") + logger.info(f"Done Updating globals: {library_worker.__file__}") - - init_response = await library_worker.worker_init_request(init_request) + init_request = WorkerRequest(name="WorkerInitRequest", + request=request, + properties={"protos": protos, + "host": self._host}) + try: + init_response = await library_worker.worker_init_request(init_request) + except Exception as e: + logger.info(f"Exception from init: {e}") logger.info("Finished WorkerInitRequest, request ID %s, worker id %s, ", self.request_id, self.worker_id) From 4764ce7a0f7ec202ca2951efc4ea35f334182b42 Mon Sep 17 00:00:00 2001 From: Gavin Aguiar Date: Thu, 20 Feb 2025 15:14:56 -0600 Subject: [PATCH 13/42] Updated sys path --- proxy_worker/utils/dependency.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/proxy_worker/utils/dependency.py b/proxy_worker/utils/dependency.py index 4331deaf9..6d966cf52 100644 --- a/proxy_worker/utils/dependency.py +++ b/proxy_worker/utils/dependency.py @@ -172,9 +172,10 @@ def prioritize_customer_dependencies(cls, cx_working_dir=None): is_envvar_true("WEBSITE_PLACEHOLDER_MODE"), sys.path) 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_to_sys_path(working_directory, False) - cls._add_to_sys_path(cls.worker_deps_path, False) + logger.info(f'Finished prioritize_customer_dependencies: {sys.path}') From 53a3d4e7d0c928366f1edbc14576fc8e1b26341d Mon Sep 17 00:00:00 2001 From: Gavin Aguiar Date: Tue, 25 Feb 2025 15:53:56 -0600 Subject: [PATCH 14/42] Dispatcher and dependency manager updates --- proxy_worker/dispatcher.py | 47 ++++++++++++++++++++++---------- proxy_worker/utils/dependency.py | 10 ++++--- 2 files changed, 39 insertions(+), 18 deletions(-) diff --git a/proxy_worker/dispatcher.py b/proxy_worker/dispatcher.py index 09363d7fc..acbc307a4 100644 --- a/proxy_worker/dispatcher.py +++ b/proxy_worker/dispatcher.py @@ -1,6 +1,5 @@ import asyncio import concurrent.futures -import importlib.util import logging import queue import sys @@ -402,12 +401,19 @@ async def _handle__worker_init_request(self, request): library_worker = azure_functions_worker_v1 logger.info(f"V1 worker Import succeeded: {library_worker.__file__}") except ImportError: - logger.info("Trying to import v2 worker") - import azure_functions_worker - library_worker = azure_functions_worker - logger.info(f"V2 worker Import succeeded: {library_worker.__file__}") + try: + logger.info("Trying to import v2 worker") + import azure.functions as func + logger.info(f"Func Import succeeded: {func.__file__}") + import azure_functions_worker_v2 + library_worker = azure_functions_worker_v2 + logger.info(f"V2 worker Import succeeded: {library_worker.__file__}") + except ImportError as e: + logger.info(f"Import error: {traceback.format_exception(etype=type(e), tb=e.__traceback__, value=e)}") + except Exception as e: + logger.info(f"Error importing V2: {traceback.format_exception(etype=type(e), tb=e.__traceback__, value=e)}") except Exception as e: - logger.info(f"Some other ex: {e}") + logger.info(f"Some other ex: {traceback.format_exception(etype=type(e), tb=e.__traceback__, value=e)}") logger.info(f"Done Updating globals: {library_worker.__file__}") @@ -435,18 +441,31 @@ async def _handle__function_environment_reload_request(self, request): self.request_id, python_appsetting_state()) - func_env_reload_request = \ - request.function_environment_reload_request - directory = func_env_reload_request.function_app_directory - DependencyManager.reload_customer_libraries(directory) + DependencyManager.prioritize_customer_dependencies() + global library_worker try: - import azure_functions_worker_v1 as worker + logger.info("Trying to import v1 worker") + import azure_functions_worker_v1 + library_worker = azure_functions_worker_v1 + logger.info(f"V1 worker Import succeeded: {library_worker.__file__}") except ImportError: - import azure_functions_worker as worker + try: + logger.info("Trying to import v2 worker") + import azure.functions as func + logger.info(f"Func Import succeeded: {func.__file__}") + import azure_functions_worker_v2 + library_worker = azure_functions_worker_v2 + logger.info(f"V2 worker Import succeeded: {library_worker.__file__}") + except ImportError as e: + logger.info(f"Import error: {traceback.format_exception(etype=type(e), tb=e.__traceback__, value=e)}") + except Exception as e: + logger.info( + f"Error importing V2: {traceback.format_exception(etype=type(e), tb=e.__traceback__, value=e)}") + except Exception as e: + logger.info(f"Some other ex: {traceback.format_exception(etype=type(e), tb=e.__traceback__, value=e)}") - global library_worker - library_worker = worker + logger.info(f"Done Updating globals: {library_worker.__file__}") env_reload_request = WorkerRequest(name="FunctionEnvironmentReloadRequest", request=request, properties={"protos": protos, diff --git a/proxy_worker/utils/dependency.py b/proxy_worker/utils/dependency.py index 6d966cf52..cfe2e3902 100644 --- a/proxy_worker/utils/dependency.py +++ b/proxy_worker/utils/dependency.py @@ -8,6 +8,7 @@ from types import ModuleType from typing import List, Optional +from azure_functions_worker.utils.wrappers import enable_feature_by from .common import is_envvar_true, is_true_like from .constants import AZURE_WEBJOBS_SCRIPT_ROOT, CONTAINER_NAME, PYTHON_ISOLATE_WORKER_DEPENDENCIES from ..logging import logger @@ -103,7 +104,7 @@ def use_worker_dependencies(cls): logger.info('Start using worker dependencies %s. Sys.path: %s', cls.worker_deps_path, sys.path) @classmethod - def reload_customer_libraries(cls, cx_working_dir: str): + def reload_customer_libraries(cls, cx_working_dir: str = None): """Reload azure and google namespace, this including any modules in this namespace, such as azure-functions, grpcio, grpcio-tools etc. @@ -126,11 +127,12 @@ def reload_customer_libraries(cls, cx_working_dir: str): if isolate_dependencies: cls.prioritize_customer_dependencies(cx_working_dir) - else: - cls.reload_azure_google_namespace_from_worker_deps() - @classmethod + @enable_feature_by( + flag=PYTHON_ISOLATE_WORKER_DEPENDENCIES, + flag_default=True + ) def prioritize_customer_dependencies(cls, cx_working_dir=None): """Switch the sys.path and ensure the customer's code import are loaded from CX's deppendencies. From 5caa92266713d0805cb7ddabdadf3b302926409b Mon Sep 17 00:00:00 2001 From: Gavin Aguiar Date: Thu, 27 Mar 2025 10:39:37 -0500 Subject: [PATCH 15/42] Updated dispatcher and pyproject --- proxy_worker/dispatcher.py | 65 ++++++++++++++++++++------------------ pyproject.toml | 6 ++-- tests/test_setup.py | 25 ++++++++------- 3 files changed, 52 insertions(+), 44 deletions(-) diff --git a/proxy_worker/dispatcher.py b/proxy_worker/dispatcher.py index acbc307a4..572991718 100644 --- a/proxy_worker/dispatcher.py +++ b/proxy_worker/dispatcher.py @@ -1,6 +1,8 @@ import asyncio import concurrent.futures +import importlib.util import logging +import os import queue import sys import threading @@ -30,6 +32,7 @@ # Library worker import reloaded in init and reload request library_worker = None + class ContextEnabledTask(asyncio.Task): AZURE_INVOCATION_ID = '__azure_function_invocation_id__' @@ -103,7 +106,6 @@ def current(cls): return disp - class Dispatcher(metaclass=DispatcherMeta): _GRPC_STOP_RESPONSE = object() @@ -390,30 +392,29 @@ async def _handle__worker_init_request(self, request): self.request_id, python_appsetting_state()) - if DependencyManager.should_load_cx_dependencies(): DependencyManager.prioritize_customer_dependencies() global library_worker - try: - logger.info("Trying to import v1 worker") - import azure_functions_worker_v1 - library_worker = azure_functions_worker_v1 - logger.info(f"V1 worker Import succeeded: {library_worker.__file__}") - except ImportError: + directory = request.worker_init_request.function_app_directory + v2_directory = os.path.join(directory, 'function_app.py') + logger.info(f"V2 Directory: {v2_directory}. Path exists: {os.path.exists(v2_directory)}") + if os.path.exists(v2_directory): try: logger.info("Trying to import v2 worker") - import azure.functions as func - logger.info(f"Func Import succeeded: {func.__file__}") import azure_functions_worker_v2 library_worker = azure_functions_worker_v2 logger.info(f"V2 worker Import succeeded: {library_worker.__file__}") - except ImportError as e: - logger.info(f"Import error: {traceback.format_exception(etype=type(e), tb=e.__traceback__, value=e)}") except Exception as e: - logger.info(f"Error importing V2: {traceback.format_exception(etype=type(e), tb=e.__traceback__, value=e)}") - except Exception as e: - logger.info(f"Some other ex: {traceback.format_exception(etype=type(e), tb=e.__traceback__, value=e)}") + logger.info(f"Error when importing V2 library: {traceback.format_exc()}") + else: + try: + logger.info("Trying to import v1 worker") + import azure_functions_worker_v1 + library_worker = azure_functions_worker_v1 + logger.info(f"V1 worker Import succeeded: {library_worker.__file__}") + except Exception as e: + logger.info(f"Error when importing V1 library: {e}") logger.info(f"Done Updating globals: {library_worker.__file__}") @@ -441,35 +442,39 @@ async def _handle__function_environment_reload_request(self, request): self.request_id, python_appsetting_state()) - DependencyManager.prioritize_customer_dependencies() + func_env_reload_request = \ + request.function_environment_reload_request + directory = func_env_reload_request.function_app_directory + DependencyManager.reload_customer_libraries(directory) global library_worker - try: - logger.info("Trying to import v1 worker") - import azure_functions_worker_v1 - library_worker = azure_functions_worker_v1 - logger.info(f"V1 worker Import succeeded: {library_worker.__file__}") - except ImportError: + directory = request.worker_init_request.function_app_directory + v2_directory = os.path.join(directory, 'function_app.py') + logger.info(f"V2 Directory: {v2_directory}. Path exists: {os.path.exists(v2_directory)}") + if os.path.exists(v2_directory): try: logger.info("Trying to import v2 worker") - import azure.functions as func - logger.info(f"Func Import succeeded: {func.__file__}") import azure_functions_worker_v2 library_worker = azure_functions_worker_v2 logger.info(f"V2 worker Import succeeded: {library_worker.__file__}") - except ImportError as e: - logger.info(f"Import error: {traceback.format_exception(etype=type(e), tb=e.__traceback__, value=e)}") except Exception as e: logger.info( - f"Error importing V2: {traceback.format_exception(etype=type(e), tb=e.__traceback__, value=e)}") - except Exception as e: - logger.info(f"Some other ex: {traceback.format_exception(etype=type(e), tb=e.__traceback__, value=e)}") + f"Error when importing V2 library: {traceback.format_exception(etype=type(e), tb=e.__traceback__, value=e)}") + else: + try: + logger.info("Trying to import v1 worker") + import azure_functions_worker_v1 + library_worker = azure_functions_worker_v1 + logger.info(f"V1 worker Import succeeded: {library_worker.__file__}") + except Exception as e: + logger.info( + f"Error when importing V1 library: {traceback.format_exception(etype=type(e), tb=e.__traceback__, value=e)}") logger.info(f"Done Updating globals: {library_worker.__file__}") env_reload_request = WorkerRequest(name="FunctionEnvironmentReloadRequest", request=request, properties={"protos": protos, - "host": self._host}) + "host": self._host}) env_reload_response = await library_worker.function_environment_reload_request(env_reload_request) return protos.StreamingMessage( request_id=self.request_id, diff --git a/pyproject.toml b/pyproject.toml index e2a29a78f..33b381a13 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,11 +29,11 @@ dependencies = [ "azure-functions==1.23.0b1", "python-dateutil ~=2.9.0", "protobuf~=3.19.3; python_version == '3.7'", - "protobuf~=4.25.3; python_version >= '3.8'", + "protobuf~=5.29.0; python_version >= '3.8'", "grpcio-tools~=1.43.0; python_version == '3.7'", - "grpcio-tools~=1.59.0; python_version >= '3.8'", + "grpcio-tools~=1.70.0; python_version >= '3.8'", "grpcio~=1.43.0; python_version == '3.7'", - "grpcio~=1.59.0; python_version >= '3.8'", + "grpcio~=1.70.0; python_version >= '3.8'", "azurefunctions-extensions-base; python_version >= '3.8'" ] diff --git a/tests/test_setup.py b/tests/test_setup.py index f08bfc29c..6f9c3edd5 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -28,7 +28,6 @@ import urllib.request import zipfile from distutils import dir_util -from xml.dom import WRONG_DOCUMENT_ERR from invoke import task @@ -39,7 +38,6 @@ WEBHOST_GITHUB_API = "https://api.github.com/repos/Azure/azure-functions-host" WEBHOST_GIT_REPO = "https://github.com/Azure/azure-functions-host/archive" WEBHOST_TAG_PREFIX = "v4." -WORKER_DIR = "azure_functions_worker" if sys.version_info.minor < 13 else "proxy_worker" def get_webhost_version() -> str: @@ -112,8 +110,13 @@ def compile_webhost(webhost_dir): print(f"Compiling Functions Host from {webhost_dir}") try: subprocess.run( - ["dotnet", "build", "WebJobs.Script.sln", "-o", "bin", - "/p:TreatWarningsAsErrors=false"], + [ + "dotnet", "build", "WebJobs.Script.sln", + "/m:1", # Disable parallel MSBuild + "/nodeReuse:false", # Prevent MSBuild node reuse + f"--property:OutputPath={webhost_dir}/bin", # Set output folder + "/p:TreatWarningsAsErrors=false" + ], check=True, cwd=str(webhost_dir), stdout=sys.stdout, @@ -122,7 +125,7 @@ def compile_webhost(webhost_dir): except subprocess.CalledProcessError: print( f"Failed to compile webhost in {webhost_dir}. " - ".NET Core SDK is required to build the solution. " + "A compatible .NET Core SDK is required to build the solution. " "Please visit https://aka.ms/dotnet-download", file=sys.stderr, ) @@ -131,10 +134,10 @@ def compile_webhost(webhost_dir): def gen_grpc(): - proto_root_dir = ROOT_DIR / WORKER_DIR / "protos" + proto_root_dir = ROOT_DIR / "azure_functions_worker" / "protos" proto_src_dir = proto_root_dir / "_src" / "src" / "proto" staging_root_dir = BUILD_DIR / "protos" - staging_dir = staging_root_dir / WORKER_DIR / "protos" + staging_dir = staging_root_dir / "azure_functions_worker" / "protos" built_protos_dir = BUILD_DIR / "built_protos" if os.path.exists(BUILD_DIR): @@ -156,12 +159,12 @@ def gen_grpc(): "-m", "grpc_tools.protoc", "-I", - os.sep.join((WORKER_DIR, "protos")), + os.sep.join(("azure_functions_worker", "protos")), "--python_out", str(built_protos_dir), "--grpc_python_out", str(built_protos_dir), - os.sep.join((WORKER_DIR, "protos", proto)), + os.sep.join(("azure_functions_worker", "protos", proto)), ], check=True, stdout=sys.stdout, @@ -194,7 +197,7 @@ def make_absolute_imports(compiled_files): # from azure_functions_worker.protos import xxx_pb2 as.. p1 = re.sub( r"\nimport (.*?_pb2)", - fr"\nfrom {WORKER_DIR}.protos import \g<1>", + r"\nfrom azure_functions_worker.protos import \g<1>", content, ) # Convert lines of the form: @@ -202,7 +205,7 @@ def make_absolute_imports(compiled_files): # from azure_functions_worker.protos.identity import xxx_pb2.. p2 = re.sub( r"from ([a-z]*) (import.*_pb2)", - fr"from {WORKER_DIR}.protos.\g<1> \g<2>", + r"from azure_functions_worker.protos.\g<1> \g<2>", p1, ) f.write(p2) From dabaec6dfc8846f249a9231c5666bd8c038a577d Mon Sep 17 00:00:00 2001 From: Gavin Aguiar Date: Wed, 9 Apr 2025 15:10:21 -0500 Subject: [PATCH 16/42] Testing updates and refactoring --- proxy_worker/dispatcher.py | 105 ++++------- proxy_worker/logging.py | 2 +- proxy_worker/utils/__init__.py | 2 + proxy_worker/utils/app_settings.py | 31 +-- proxy_worker/utils/common.py | 4 +- proxy_worker/utils/constants.py | 9 +- proxy_worker/utils/dependency.py | 33 ---- pyproject.toml | 1 + pytest.ini | 5 + python/test/worker.py | 7 +- tests/test_setup.py | 16 +- .../unittests/proxy_worker/test_dispatcher.py | 178 ++++++++++++++++++ 12 files changed, 247 insertions(+), 146 deletions(-) create mode 100644 pytest.ini create mode 100644 tests/unittests/proxy_worker/test_dispatcher.py diff --git a/proxy_worker/dispatcher.py b/proxy_worker/dispatcher.py index 572991718..4934528a7 100644 --- a/proxy_worker/dispatcher.py +++ b/proxy_worker/dispatcher.py @@ -1,6 +1,5 @@ import asyncio import concurrent.futures -import importlib.util import logging import os import queue @@ -23,14 +22,14 @@ is_system_log_category, logger, ) -from proxy_worker.utils.app_settings import get_app_setting, python_appsetting_state +from proxy_worker.utils.app_settings import get_app_setting from proxy_worker.utils.common import is_envvar_true from proxy_worker.utils.constants import PYTHON_ENABLE_DEBUG_LOGGING, PYTHON_THREADPOOL_THREAD_COUNT from proxy_worker.version import VERSION from .utils.dependency import DependencyManager # Library worker import reloaded in init and reload request -library_worker = None +_library_worker = None class ContextEnabledTask(asyncio.Task): @@ -186,20 +185,6 @@ async def connect(cls, host: str, port: int, worker_id: str, logger.info('Successfully opened gRPC channel to %s:%s ', host, port) return disp - async def _initialize_grpc(self): - # Initialize gRPC-related attributes - self._grpc_resp_queue = queue.Queue() - self._grpc_connected_fut = self._loop.create_future() - self._grpc_thread = threading.Thread( - name='grpc_local-thread', target=self.__poll_grpc) - - # Start gRPC thread - self._grpc_thread.start() - - # Wait for gRPC connection to complete - await self._grpc_connected_fut - logger.info('Successfully opened gRPC channel to %s:%s', self._host, self._port) - def __poll_grpc(self): options = [] if self._grpc_max_msg_len: @@ -384,102 +369,84 @@ async def _handle__worker_init_request(self, request): 'python version %s, ' 'worker version %s, ' 'request ID %s. ' - 'App Settings state: %s. ' 'To enable debug level logging, please refer to ' 'https://aka.ms/python-enable-debug-logging', sys.version, VERSION, - self.request_id, - python_appsetting_state()) + self.request_id) if DependencyManager.should_load_cx_dependencies(): DependencyManager.prioritize_customer_dependencies() - global library_worker + global _library_worker directory = request.worker_init_request.function_app_directory v2_directory = os.path.join(directory, 'function_app.py') - logger.info(f"V2 Directory: {v2_directory}. Path exists: {os.path.exists(v2_directory)}") if os.path.exists(v2_directory): try: - logger.info("Trying to import v2 worker") - import azure_functions_worker_v2 - library_worker = azure_functions_worker_v2 - logger.info(f"V2 worker Import succeeded: {library_worker.__file__}") - except Exception as e: - logger.info(f"Error when importing V2 library: {traceback.format_exc()}") + import azure_functions_worker_v2 # NoQA + _library_worker = azure_functions_worker_v2 + logger.debug("V2 worker import succeeded: %s", _library_worker.__file__) + except ImportError: + logger.warning("Error importing V2 library: %s",traceback.format_exc()) else: try: - logger.info("Trying to import v1 worker") - import azure_functions_worker_v1 - library_worker = azure_functions_worker_v1 - logger.info(f"V1 worker Import succeeded: {library_worker.__file__}") - except Exception as e: - logger.info(f"Error when importing V1 library: {e}") - - logger.info(f"Done Updating globals: {library_worker.__file__}") + import azure_functions_worker_v1 # NoQA + _library_worker = azure_functions_worker_v1 + logger.debug("V1 worker import succeeded: %s", _library_worker.__file__) + except ImportError: + logger.warning("Error importing V1 library: %s",traceback.format_exc()) init_request = WorkerRequest(name="WorkerInitRequest", request=request, properties={"protos": protos, "host": self._host}) - try: - init_response = await library_worker.worker_init_request(init_request) - except Exception as e: - logger.info(f"Exception from init: {e}") - logger.info("Finished WorkerInitRequest, request ID %s, worker id %s, ", - self.request_id, self.worker_id) + init_response = await _library_worker.worker_init_request(init_request) return protos.StreamingMessage( request_id=self.request_id, worker_init_response=init_response) + async def _handle__function_environment_reload_request(self, request): logger.info('Received FunctionEnvironmentReloadRequest, ' 'request ID: %s, ' - 'App Settings state: %s. ' 'To enable debug level logging, please refer to ' 'https://aka.ms/python-enable-debug-logging', - self.request_id, - python_appsetting_state()) + self.request_id) func_env_reload_request = \ request.function_environment_reload_request directory = func_env_reload_request.function_app_directory DependencyManager.reload_customer_libraries(directory) - global library_worker - directory = request.worker_init_request.function_app_directory + global _library_worker + directory = func_env_reload_request.function_app_directory v2_directory = os.path.join(directory, 'function_app.py') - logger.info(f"V2 Directory: {v2_directory}. Path exists: {os.path.exists(v2_directory)}") if os.path.exists(v2_directory): try: - logger.info("Trying to import v2 worker") - import azure_functions_worker_v2 - library_worker = azure_functions_worker_v2 - logger.info(f"V2 worker Import succeeded: {library_worker.__file__}") - except Exception as e: - logger.info( - f"Error when importing V2 library: {traceback.format_exception(etype=type(e), tb=e.__traceback__, value=e)}") + import azure_functions_worker_v2 # NoQA + _library_worker = azure_functions_worker_v2 + logger.debug("V2 worker import succeeded: %s",_library_worker.__file__) + except ImportError: + logger.warning("Error importing V2 library: %s",traceback.format_exc()) else: try: - logger.info("Trying to import v1 worker") - import azure_functions_worker_v1 - library_worker = azure_functions_worker_v1 - logger.info(f"V1 worker Import succeeded: {library_worker.__file__}") - except Exception as e: - logger.info( - f"Error when importing V1 library: {traceback.format_exception(etype=type(e), tb=e.__traceback__, value=e)}") - - logger.info(f"Done Updating globals: {library_worker.__file__}") + import azure_functions_worker_v1 # NoQA + _library_worker = azure_functions_worker_v1 + logger.debug("V1 worker import succeeded: %s",_library_worker.__file__) + except ImportError: + logger.warning("Error importing V1 library: %s",traceback.format_exc()) env_reload_request = WorkerRequest(name="FunctionEnvironmentReloadRequest", request=request, properties={"protos": protos, "host": self._host}) - env_reload_response = await library_worker.function_environment_reload_request(env_reload_request) + env_reload_response = await _library_worker.function_environment_reload_request(env_reload_request) + return protos.StreamingMessage( request_id=self.request_id, function_environment_reload_response=env_reload_response) + async def _handle__worker_status_request(self, request): # Logging is not necessary in this request since the response is used # for host to judge scale decisions of out-of-proc languages. @@ -488,6 +455,7 @@ async def _handle__worker_status_request(self, request): request_id=request.request_id, worker_status_response=protos.WorkerStatusResponse()) + async def _handle__functions_metadata_request(self, request): logger.info( 'Received WorkerMetadataRequest, request ID %s, ' @@ -495,7 +463,7 @@ async def _handle__functions_metadata_request(self, request): self.request_id, self.worker_id) metadata_request = WorkerRequest(name="WorkerMetadataRequest", request=request) - metadata_response = await library_worker.functions_metadata_request(metadata_request) + metadata_response = await _library_worker.functions_metadata_request(metadata_request) return protos.StreamingMessage( request_id=request.request_id, @@ -513,7 +481,7 @@ async def _handle__function_load_request(self, request): self.request_id, function_id, function_name, self.worker_id) load_request = WorkerRequest(name="FunctionsLoadRequest", request=request) - load_response = await library_worker.function_load_request(load_request) + load_response = await _library_worker.function_load_request(load_request) return protos.StreamingMessage( request_id=self.request_id, @@ -531,7 +499,8 @@ async def _handle__invocation_request(self, request): invocation_request = WorkerRequest(name="WorkerInvRequest", request=request, properties={"threadpool": self._sync_call_tp}) - invocation_response = await library_worker.invocation_request(invocation_request) + invocation_response = await _library_worker.invocation_request(invocation_request) + return protos.StreamingMessage( request_id=self.request_id, invocation_response=invocation_response) diff --git a/proxy_worker/logging.py b/proxy_worker/logging.py index 7c723426b..8f765640b 100644 --- a/proxy_worker/logging.py +++ b/proxy_worker/logging.py @@ -9,7 +9,7 @@ # Logging Prefixes SYSTEM_LOG_PREFIX = "proxy_worker" SDK_LOG_PREFIX = "azure.functions" -SYSTEM_ERROR_LOG_PREFIX = "azure_functions_worker_errors" +SYSTEM_ERROR_LOG_PREFIX = "proxy_worker_errors" CONSOLE_LOG_PREFIX = "LanguageWorkerConsoleLog" diff --git a/proxy_worker/utils/__init__.py b/proxy_worker/utils/__init__.py index e69de29bb..6fcf0de49 100644 --- a/proxy_worker/utils/__init__.py +++ b/proxy_worker/utils/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. \ No newline at end of file diff --git a/proxy_worker/utils/app_settings.py b/proxy_worker/utils/app_settings.py index ed8487ec3..af6fdcb39 100644 --- a/proxy_worker/utils/app_settings.py +++ b/proxy_worker/utils/app_settings.py @@ -1,15 +1,9 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + import os from typing import Callable, Optional -from .constants import ( - PYTHON_ENABLE_DEBUG_LOGGING, - PYTHON_ENABLE_INIT_INDEXING, - PYTHON_ENABLE_OPENTELEMETRY, - PYTHON_SCRIPT_FILE_NAME, - PYTHON_THREADPOOL_THREAD_COUNT, -) - - def get_app_setting( setting: str, default_value: Optional[str] = None, @@ -51,22 +45,3 @@ def get_app_setting( if validator(app_setting_value): return app_setting_value return default_value - - -def python_appsetting_state(): - current_vars = os.environ.copy() - python_specific_settings = \ - [ - PYTHON_THREADPOOL_THREAD_COUNT, - PYTHON_ENABLE_DEBUG_LOGGING, - PYTHON_SCRIPT_FILE_NAME, - PYTHON_ENABLE_INIT_INDEXING, - PYTHON_ENABLE_OPENTELEMETRY] - - app_setting_states = "".join( - f"{app_setting}: {current_vars[app_setting]} | " - for app_setting in python_specific_settings - if app_setting in current_vars - ) - - return app_setting_states diff --git a/proxy_worker/utils/common.py b/proxy_worker/utils/common.py index 2c212286d..f371e97d0 100644 --- a/proxy_worker/utils/common.py +++ b/proxy_worker/utils/common.py @@ -1,5 +1,7 @@ -import os +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import os def is_true_like(setting: str) -> bool: if setting is None: diff --git a/proxy_worker/utils/constants.py b/proxy_worker/utils/constants.py index 899b0433f..904a07aa5 100644 --- a/proxy_worker/utils/constants.py +++ b/proxy_worker/utils/constants.py @@ -1,11 +1,10 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + # App Setting constants PYTHON_ENABLE_DEBUG_LOGGING = "PYTHON_ENABLE_DEBUG_LOGGING" -PYTHON_THREADPOOL_THREAD_COUNT = "PYTHON_THREADPOOL_THREAD_COUNT" -PYTHON_SCRIPT_FILE_NAME = "PYTHON_SCRIPT_FILE_NAME" -PYTHON_ENABLE_INIT_INDEXING = "PYTHON_ENABLE_INIT_INDEXING" -PYTHON_ENABLE_OPENTELEMETRY= "PYTHON_ENABLE_OPENTELEMETRY" PYTHON_ISOLATE_WORKER_DEPENDENCIES = "PYTHON_ISOLATE_WORKER_DEPENDENCIES" - +PYTHON_THREADPOOL_THREAD_COUNT = "PYTHON_THREADPOOL_THREAD_COUNT" CONTAINER_NAME = "CONTAINER_NAME" AZURE_WEBJOBS_SCRIPT_ROOT = "AzureWebJobsScriptRoot" diff --git a/proxy_worker/utils/dependency.py b/proxy_worker/utils/dependency.py index cfe2e3902..f605eea92 100644 --- a/proxy_worker/utils/dependency.py +++ b/proxy_worker/utils/dependency.py @@ -181,39 +181,6 @@ def prioritize_customer_dependencies(cls, cx_working_dir=None): logger.info(f'Finished prioritize_customer_dependencies: {sys.path}') - @classmethod - def reload_azure_google_namespace_from_worker_deps(cls): - """This is the old implementation of reloading azure and google - namespace in Python worker directory. It is not actually re-importing - the module but only reloads the module scripts from the worker path. - - It is not doing what it is intended, but due to it is already released - on Linux Consumption production, we don't want to introduce regression - on existing customers. - - Only intended to be used in Linux Consumption scenario. - """ - # Reload package namespaces for customer's libraries - packages_to_reload = [ 'azure', 'google'] - packages_reloaded = [] - for p in packages_to_reload: - try: - importlib.reload(sys.modules[p]) - packages_reloaded.append(p) - except Exception as ex: - logger.warning('Unable to reload %s: \n%s', p, ex) - - logger.info(f'Reloaded modules: {",".join(packages_reloaded)}') - - # Reload azure.functions to give user package precedence - try: - importlib.reload(sys.modules['azure.functions']) - logger.info('Reloaded azure.functions module now at %s', - inspect.getfile(sys.modules['azure.functions'])) - except Exception as ex: - logger.warning( - 'Unable to reload azure.functions. Using default. ' - 'Exception:\n%s', ex) @classmethod def _add_to_sys_path(cls, path: str, add_to_first: bool): diff --git a/pyproject.toml b/pyproject.toml index 92e7355ae..109e1c34b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,7 @@ dev = [ "pytest-randomly", "pytest-instafail", "pytest-rerunfailures", + "pytest-asyncio", "ptvsd", "python-dotenv", "plotly", diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 000000000..b4a9533ab --- /dev/null +++ b/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +markers = +asyncio: mark a test as an asyncio test +asyncio_mode = auto +asyncio_default_fixture_loop_scope = function \ No newline at end of file diff --git a/python/test/worker.py b/python/test/worker.py index e2ef12d22..a15d160ed 100644 --- a/python/test/worker.py +++ b/python/test/worker.py @@ -1,7 +1,7 @@ import sys import os from azure_functions_worker import main - +from proxy_worker import start_worker # Azure environment variables AZURE_WEBJOBS_SCRIPT_ROOT = "AzureWebJobsScriptRoot" @@ -16,4 +16,7 @@ def add_script_root_to_sys_path(): if __name__ == '__main__': add_script_root_to_sys_path() - main.main() + if sys.version_info.minor >= 13: + start_worker.start() + else: + main.main() diff --git a/tests/test_setup.py b/tests/test_setup.py index 6f9c3edd5..52cd85680 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -27,7 +27,6 @@ import tempfile import urllib.request import zipfile -from distutils import dir_util from invoke import task @@ -38,6 +37,7 @@ WEBHOST_GITHUB_API = "https://api.github.com/repos/Azure/azure-functions-host" WEBHOST_GIT_REPO = "https://github.com/Azure/azure-functions-host/archive" WEBHOST_TAG_PREFIX = "v4." +WORKER_DIR = "azure_functions_worker" if sys.version_info.minor < 13 else "proxy_worker" def get_webhost_version() -> str: @@ -134,10 +134,10 @@ def compile_webhost(webhost_dir): def gen_grpc(): - proto_root_dir = ROOT_DIR / "azure_functions_worker" / "protos" + proto_root_dir = ROOT_DIR / WORKER_DIR / "protos" proto_src_dir = proto_root_dir / "_src" / "src" / "proto" staging_root_dir = BUILD_DIR / "protos" - staging_dir = staging_root_dir / "azure_functions_worker" / "protos" + staging_dir = staging_root_dir / WORKER_DIR / "protos" built_protos_dir = BUILD_DIR / "built_protos" if os.path.exists(BUILD_DIR): @@ -159,12 +159,12 @@ def gen_grpc(): "-m", "grpc_tools.protoc", "-I", - os.sep.join(("azure_functions_worker", "protos")), + os.sep.join((WORKER_DIR, "protos")), "--python_out", str(built_protos_dir), "--grpc_python_out", str(built_protos_dir), - os.sep.join(("azure_functions_worker", "protos", proto)), + os.sep.join((WORKER_DIR, "protos", proto)), ], check=True, stdout=sys.stdout, @@ -184,7 +184,7 @@ def gen_grpc(): # https://github.com/protocolbuffers/protobuf/issues/1491 make_absolute_imports(compiled_files) - dir_util.copy_tree(str(built_protos_dir), str(proto_root_dir)) + shutil.copytree(str(built_protos_dir), str(proto_root_dir), dirs_exist_ok=True) def make_absolute_imports(compiled_files): @@ -197,7 +197,7 @@ def make_absolute_imports(compiled_files): # from azure_functions_worker.protos import xxx_pb2 as.. p1 = re.sub( r"\nimport (.*?_pb2)", - r"\nfrom azure_functions_worker.protos import \g<1>", + fr"\nfrom {WORKER_DIR}.protos import \g<1>", content, ) # Convert lines of the form: @@ -205,7 +205,7 @@ def make_absolute_imports(compiled_files): # from azure_functions_worker.protos.identity import xxx_pb2.. p2 = re.sub( r"from ([a-z]*) (import.*_pb2)", - r"from azure_functions_worker.protos.\g<1> \g<2>", + fr"from {WORKER_DIR}.protos.\g<1> \g<2>", p1, ) f.write(p2) diff --git a/tests/unittests/proxy_worker/test_dispatcher.py b/tests/unittests/proxy_worker/test_dispatcher.py new file mode 100644 index 000000000..10a5c3f05 --- /dev/null +++ b/tests/unittests/proxy_worker/test_dispatcher.py @@ -0,0 +1,178 @@ +import asyncio +import builtins +import logging +import types +import unittest +from unittest.mock import Mock, patch, MagicMock, AsyncMock, ANY + +import pytest + +from proxy_worker.dispatcher import Dispatcher + + +class TestDispatcher(unittest.TestCase): + + @patch("proxy_worker.dispatcher.queue.Queue") + @patch("proxy_worker.dispatcher.threading.Thread") + def test_dispatcher_initialization(self, mock_thread, mock_queue): + # Arrange + mock_loop = Mock() + mock_future = Mock() + mock_loop.create_future.return_value = mock_future + + # Act + dispatcher = Dispatcher( + loop=mock_loop, + host="127.0.0.1", + port=7070, + worker_id="worker123", + request_id="req456", + grpc_connect_timeout=5.0, + grpc_max_msg_len=1024 + ) + + # Assert + self.assertEqual(dispatcher._host, "127.0.0.1") + self.assertEqual(dispatcher._port, 7070) + self.assertEqual(dispatcher._worker_id, "worker123") + self.assertEqual(dispatcher._request_id, "req456") + self.assertEqual(dispatcher._grpc_connect_timeout, 5.0) + self.assertEqual(dispatcher._grpc_max_msg_len, 1024) + self.assertEqual(dispatcher._grpc_connected_fut, mock_future) + mock_queue.assert_called_once() + mock_thread.assert_called_once() + + @patch("proxy_worker.dispatcher.protos.StreamingMessage") + @patch("proxy_worker.dispatcher.protos.RpcLog") + @patch("proxy_worker.dispatcher.is_system_log_category") + def test_on_logging_levels_and_categories(self, mock_is_system, mock_rpc_log, mock_streaming_message): + loop = Mock() + dispatcher = Dispatcher(loop, "localhost", 5000, "worker", + "req", 5.0) + + mock_rpc_log.return_value = Mock() + mock_streaming_message.return_value = Mock() + + levels = [ + (logging.CRITICAL, mock_rpc_log.Critical), + (logging.ERROR, mock_rpc_log.Error), + (logging.WARNING, mock_rpc_log.Warning), + (logging.INFO, mock_rpc_log.Information), + (logging.DEBUG, mock_rpc_log.Debug), + (5, getattr(mock_rpc_log, 'None')), + ] + + for level, expected in levels: + record = Mock(levelno=level, name="custom.logger") + mock_is_system.return_value = level % 2 == 0 # alternate True/False + dispatcher.on_logging(record, "Test message") + + if mock_is_system.return_value: + mock_rpc_log.RpcLogCategory.Value.assert_called_with("System") + else: + mock_rpc_log.RpcLogCategory.Value.assert_called_with("User") + + +def fake_import(name, globals=None, locals=None, fromlist=(), level=0): + mock_module = types.SimpleNamespace(__file__=f"{name}.py") + mock_module.worker_init_request = AsyncMock(return_value="fake_response") + mock_module.function_environment_reload_request = AsyncMock(return_value="mocked_env_reload_response") + if name in ["azure_functions_worker_v2", "azure_functions_worker_v1"]: + return mock_module + return builtins.__import__(name, globals, locals, fromlist, level) + + + +@patch("proxy_worker.dispatcher.DependencyManager.should_load_cx_dependencies", return_value=True) +@patch("proxy_worker.dispatcher.DependencyManager.prioritize_customer_dependencies") +@patch("proxy_worker.dispatcher.logger") +@patch("proxy_worker.dispatcher.os.path.exists", side_effect=lambda p: p.endswith("function_app.py")) +@patch("builtins.__import__", side_effect=fake_import) +@patch("proxy_worker.dispatcher.protos.StreamingMessage", return_value="mocked_streaming_response") +@pytest.mark.asyncio +async def test_worker_init_v2_import( + mock_streaming, mock_import, mock_exists, mock_logger, mock_prioritize, mock_should_load +): + dispatcher = Dispatcher(asyncio.get_event_loop(), "localhost", 7071, "worker123", + "req789", 5.0) + request = MagicMock() + request.worker_init_request.function_app_directory = "/home/site/wwwroot" + + result = await dispatcher._handle__worker_init_request(request) + + assert result == "mocked_streaming_response" + mock_logger.debug.assert_any_call("V2 worker import succeeded: %s", ANY) + + +@patch("proxy_worker.dispatcher.DependencyManager.should_load_cx_dependencies", return_value=True) +@patch("proxy_worker.dispatcher.DependencyManager.prioritize_customer_dependencies") +@patch("proxy_worker.dispatcher.logger") +@patch("proxy_worker.dispatcher.os.path.exists", side_effect=lambda p: False) +@patch("builtins.__import__", side_effect=fake_import) +@patch("proxy_worker.dispatcher.protos.StreamingMessage", return_value="mocked_streaming_response") +@pytest.mark.asyncio +async def test_worker_init_fallback_to_v1( + mock_streaming, mock_import, mock_exists, mock_logger, mock_prioritize, mock_should_load +): + dispatcher = Dispatcher(asyncio.get_event_loop(), "localhost", 7071, "worker123", + "req789", 5.0) + request = MagicMock() + request.worker_init_request.function_app_directory = "/home/site/wwwroot" + + result = await dispatcher._handle__worker_init_request(request) + + assert result == "mocked_streaming_response" + mock_logger.debug.assert_any_call("V1 worker import succeeded: %s", ANY) + +@patch("proxy_worker.dispatcher.DependencyManager.reload_customer_libraries") +@patch("proxy_worker.dispatcher.protos.StreamingMessage", return_value="mocked_streaming_message") +@patch("proxy_worker.dispatcher.os.path.exists", side_effect=lambda p: p.endswith("function_app.py")) +@patch("builtins.__import__", side_effect=fake_import) +@patch("proxy_worker.dispatcher.logger") +@pytest.mark.asyncio +async def test_function_environment_reload_v2_success( + mock_logger, mock_import, mock_exists, mock_streaming, mock_reload_libs +): + dispatcher = Dispatcher( + loop=MagicMock(), + host="localhost", + port=7071, + worker_id="worker-test", + request_id="req-abc", + grpc_connect_timeout=5.0, + ) + + request = MagicMock() + request.function_environment_reload_request.function_app_directory = "/home/site/wwwroot" + + result = await dispatcher._handle__function_environment_reload_request(request) + + assert result == "mocked_streaming_message" + mock_logger.debug.assert_any_call("V2 worker import succeeded: %s", ANY) + +@patch("proxy_worker.dispatcher.DependencyManager.reload_customer_libraries") +@patch("proxy_worker.dispatcher.protos.StreamingMessage", return_value="mocked_streaming_message") +@patch("proxy_worker.dispatcher.os.path.exists", side_effect=lambda p: False) +@patch("builtins.__import__", side_effect=fake_import) +@patch("proxy_worker.dispatcher.logger") +@pytest.mark.asyncio +async def test_function_environment_reload_v1_success( + mock_logger, mock_import, mock_exists, mock_streaming, mock_reload_libs +): + dispatcher = Dispatcher( + loop=MagicMock(), + host="localhost", + port=7071, + worker_id="worker-test", + request_id="req-abc", + grpc_connect_timeout=5.0, + ) + + request = MagicMock() + request.function_environment_reload_request.function_app_directory = "/home/site/wwwroot" + + result = await dispatcher._handle__function_environment_reload_request(request) + + assert result == "mocked_streaming_message" + mock_logger.debug.assert_any_call("V1 worker import succeeded: %s", ANY) + From 7f038bb9fe642bf18dbfdb18a95ebb81089d7bc6 Mon Sep 17 00:00:00 2001 From: Gavin Aguiar Date: Wed, 9 Apr 2025 16:46:51 -0500 Subject: [PATCH 17/42] Bug fixes and refactoring --- proxy_worker/dispatcher.py | 2 +- proxy_worker/utils/constants.py | 1 - proxy_worker/utils/dependency.py | 32 +------------------------------- 3 files changed, 2 insertions(+), 33 deletions(-) diff --git a/proxy_worker/dispatcher.py b/proxy_worker/dispatcher.py index 4934528a7..50e375d08 100644 --- a/proxy_worker/dispatcher.py +++ b/proxy_worker/dispatcher.py @@ -417,7 +417,7 @@ async def _handle__function_environment_reload_request(self, request): func_env_reload_request = \ request.function_environment_reload_request directory = func_env_reload_request.function_app_directory - DependencyManager.reload_customer_libraries(directory) + DependencyManager.prioritize_customer_dependencies(directory) global _library_worker directory = func_env_reload_request.function_app_directory diff --git a/proxy_worker/utils/constants.py b/proxy_worker/utils/constants.py index 904a07aa5..d94075cf7 100644 --- a/proxy_worker/utils/constants.py +++ b/proxy_worker/utils/constants.py @@ -3,7 +3,6 @@ # App Setting constants PYTHON_ENABLE_DEBUG_LOGGING = "PYTHON_ENABLE_DEBUG_LOGGING" -PYTHON_ISOLATE_WORKER_DEPENDENCIES = "PYTHON_ISOLATE_WORKER_DEPENDENCIES" PYTHON_THREADPOOL_THREAD_COUNT = "PYTHON_THREADPOOL_THREAD_COUNT" CONTAINER_NAME = "CONTAINER_NAME" diff --git a/proxy_worker/utils/dependency.py b/proxy_worker/utils/dependency.py index f605eea92..e80613815 100644 --- a/proxy_worker/utils/dependency.py +++ b/proxy_worker/utils/dependency.py @@ -8,9 +8,8 @@ from types import ModuleType from typing import List, Optional -from azure_functions_worker.utils.wrappers import enable_feature_by from .common import is_envvar_true, is_true_like -from .constants import AZURE_WEBJOBS_SCRIPT_ROOT, CONTAINER_NAME, PYTHON_ISOLATE_WORKER_DEPENDENCIES +from .constants import AZURE_WEBJOBS_SCRIPT_ROOT, CONTAINER_NAME from ..logging import logger @@ -104,35 +103,6 @@ def use_worker_dependencies(cls): logger.info('Start using worker dependencies %s. Sys.path: %s', cls.worker_deps_path, sys.path) @classmethod - def reload_customer_libraries(cls, cx_working_dir: str = None): - """Reload azure and google namespace, this including any modules in - this namespace, such as azure-functions, grpcio, grpcio-tools etc. - - Depends on the PYTHON_ISOLATE_WORKER_DEPENDENCIES, the actual behavior - differs. - - This is called only when placeholder mode is true. In the case of a - worker restart, this will not be called. - - Parameters - ---------- - cx_working_dir: str - The path which contains customer's project file (e.g. wwwroot). - """ - isolate_dependencies_setting = os.getenv(PYTHON_ISOLATE_WORKER_DEPENDENCIES) - if isolate_dependencies_setting is None: - isolate_dependencies = True - else: - isolate_dependencies = is_true_like(isolate_dependencies_setting) - - if isolate_dependencies: - cls.prioritize_customer_dependencies(cx_working_dir) - - @classmethod - @enable_feature_by( - flag=PYTHON_ISOLATE_WORKER_DEPENDENCIES, - flag_default=True - ) def prioritize_customer_dependencies(cls, cx_working_dir=None): """Switch the sys.path and ensure the customer's code import are loaded from CX's deppendencies. From 3ff686d75ac3ea28fd5393f5d86f96b90583c0f3 Mon Sep 17 00:00:00 2001 From: Gavin Aguiar Date: Thu, 10 Apr 2025 14:48:47 -0500 Subject: [PATCH 18/42] Added more unit tests --- .../unittests/proxy_worker/test_dispatcher.py | 114 +++++++++++++----- 1 file changed, 83 insertions(+), 31 deletions(-) diff --git a/tests/unittests/proxy_worker/test_dispatcher.py b/tests/unittests/proxy_worker/test_dispatcher.py index 10a5c3f05..fc3a1b933 100644 --- a/tests/unittests/proxy_worker/test_dispatcher.py +++ b/tests/unittests/proxy_worker/test_dispatcher.py @@ -124,55 +124,107 @@ async def test_worker_init_fallback_to_v1( assert result == "mocked_streaming_response" mock_logger.debug.assert_any_call("V1 worker import succeeded: %s", ANY) -@patch("proxy_worker.dispatcher.DependencyManager.reload_customer_libraries") -@patch("proxy_worker.dispatcher.protos.StreamingMessage", return_value="mocked_streaming_message") +@patch("proxy_worker.dispatcher.DependencyManager.prioritize_customer_dependencies") +@patch("proxy_worker.dispatcher.logger") @patch("proxy_worker.dispatcher.os.path.exists", side_effect=lambda p: p.endswith("function_app.py")) @patch("builtins.__import__", side_effect=fake_import) -@patch("proxy_worker.dispatcher.logger") +@patch("proxy_worker.dispatcher.protos.StreamingMessage", return_value="mocked_reload_response") @pytest.mark.asyncio -async def test_function_environment_reload_v2_success( - mock_logger, mock_import, mock_exists, mock_streaming, mock_reload_libs +async def test_function_environment_reload_v2_import( + mock_streaming, mock_import, mock_exists, mock_logger, mock_prioritize ): - dispatcher = Dispatcher( - loop=MagicMock(), - host="localhost", - port=7071, - worker_id="worker-test", - request_id="req-abc", - grpc_connect_timeout=5.0, - ) - + dispatcher = Dispatcher(asyncio.get_event_loop(), "localhost", 7071, "worker123", + "req789", 5.0) request = MagicMock() request.function_environment_reload_request.function_app_directory = "/home/site/wwwroot" result = await dispatcher._handle__function_environment_reload_request(request) - assert result == "mocked_streaming_message" + assert result == "mocked_reload_response" mock_logger.debug.assert_any_call("V2 worker import succeeded: %s", ANY) -@patch("proxy_worker.dispatcher.DependencyManager.reload_customer_libraries") -@patch("proxy_worker.dispatcher.protos.StreamingMessage", return_value="mocked_streaming_message") +@patch("proxy_worker.dispatcher.DependencyManager.prioritize_customer_dependencies") +@patch("proxy_worker.dispatcher.logger") @patch("proxy_worker.dispatcher.os.path.exists", side_effect=lambda p: False) @patch("builtins.__import__", side_effect=fake_import) -@patch("proxy_worker.dispatcher.logger") +@patch("proxy_worker.dispatcher.protos.StreamingMessage", return_value="mocked_reload_response") @pytest.mark.asyncio -async def test_function_environment_reload_v1_success( - mock_logger, mock_import, mock_exists, mock_streaming, mock_reload_libs +async def test_function_environment_reload_fallback_to_v1( + mock_streaming, mock_import, mock_exists, mock_logger, mock_prioritize ): - dispatcher = Dispatcher( - loop=MagicMock(), - host="localhost", - port=7071, - worker_id="worker-test", - request_id="req-abc", - grpc_connect_timeout=5.0, + dispatcher = Dispatcher(asyncio.get_event_loop(), "localhost", 7071, "worker123", + "req789", 5.0) + request = MagicMock() + request.function_environment_reload_request.function_app_directory = "/some/path" + + result = await dispatcher._handle__function_environment_reload_request(request) + + assert result == "mocked_reload_response" + mock_logger.debug.assert_any_call("V1 worker import succeeded: %s", "azure_functions_worker_v1.py") + + +@patch("proxy_worker.dispatcher._library_worker", + new=MagicMock(functions_metadata_request=AsyncMock(return_value="mocked_meta_resp"))) +@patch("proxy_worker.dispatcher.protos.StreamingMessage", return_value="mocked_response") +@patch("proxy_worker.dispatcher.logger") +@pytest.mark.asyncio +async def test_handle_functions_metadata_request(mock_logger, mock_streaming): + dispatcher = Dispatcher(asyncio.get_event_loop(), "localhost", 7071, "worker123", + "req789", 5.0) + request = MagicMock() + request.request_id = "req789" + + result = await dispatcher._handle__functions_metadata_request(request) + + assert result == "mocked_response" + mock_logger.info.assert_called_with( + 'Received WorkerMetadataRequest, request ID %s, worker id: %s', + "req789", "worker123" ) + + +@patch("proxy_worker.dispatcher._library_worker", + new=MagicMock(function_load_request=AsyncMock(return_value="mocked_load_response"))) +@patch("proxy_worker.dispatcher.protos.StreamingMessage", return_value="mocked_stream_response") +@patch("proxy_worker.dispatcher.logger") +@pytest.mark.asyncio +async def test_handle_function_load_request(mock_logger, mock_streaming): + dispatcher = Dispatcher(asyncio.get_event_loop(), "localhost", 7071, "worker123", + "req789", 5.0) + request = MagicMock() - request.function_environment_reload_request.function_app_directory = "/home/site/wwwroot" + request.function_load_request.function_id = "func123" + request.function_load_request.metadata.name = "hello_function" + request.request_id = "req789" - result = await dispatcher._handle__function_environment_reload_request(request) + result = await dispatcher._handle__function_load_request(request) - assert result == "mocked_streaming_message" - mock_logger.debug.assert_any_call("V1 worker import succeeded: %s", ANY) + assert result == "mocked_stream_response" + mock_logger.info.assert_called_with( + 'Received WorkerLoadRequest, request ID %s, function_id: %s,function_name: %s, worker_id: %s', + "req789", "func123", "hello_function", "worker123" + ) + +@patch("proxy_worker.dispatcher._library_worker", + new=MagicMock(invocation_request=AsyncMock(return_value="mocked_invoc_response"))) +@patch("proxy_worker.dispatcher.protos.StreamingMessage", return_value="mocked_streaming_response") +@patch("proxy_worker.dispatcher.logger") +@pytest.mark.asyncio +async def test_handle_invocation_request(mock_logger, mock_streaming): + dispatcher = Dispatcher(asyncio.get_event_loop(), "localhost", 7071, "worker123", + "req789", 5.0) + + request = MagicMock() + request.invocation_request.invocation_id = "inv123" + request.invocation_request.function_id = "func123" + request.request_id = "req789" + + result = await dispatcher._handle__invocation_request(request) + + assert result == "mocked_streaming_response" + mock_logger.info.assert_called_with( + 'Received FunctionInvocationRequest, request ID %s, function_id: %s,invocation_id: %s, worker_id: %s', + "req789", "func123", "inv123", "worker123" + ) \ No newline at end of file From 5ade8e35c51713caf4f5bb0a68ea4ce881dd07bb Mon Sep 17 00:00:00 2001 From: Gavin Aguiar Date: Thu, 10 Apr 2025 15:59:01 -0500 Subject: [PATCH 19/42] Added tests and fixed test setup --- tests/test_setup.py | 4 +- .../unittests/proxy_worker/test_dependency.py | 57 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 tests/unittests/proxy_worker/test_dependency.py diff --git a/tests/test_setup.py b/tests/test_setup.py index 52cd85680..627538a57 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -184,7 +184,9 @@ def gen_grpc(): # https://github.com/protocolbuffers/protobuf/issues/1491 make_absolute_imports(compiled_files) - shutil.copytree(str(built_protos_dir), str(proto_root_dir), dirs_exist_ok=True) + if os.path.exists(str(proto_root_dir)): + shutil.rmtree(str(proto_root_dir)) + shutil.copytree(str(built_protos_dir), str(proto_root_dir)) def make_absolute_imports(compiled_files): diff --git a/tests/unittests/proxy_worker/test_dependency.py b/tests/unittests/proxy_worker/test_dependency.py new file mode 100644 index 000000000..2d7aab150 --- /dev/null +++ b/tests/unittests/proxy_worker/test_dependency.py @@ -0,0 +1,57 @@ +import sys +import os +from unittest.mock import patch + +from proxy_worker.utils.dependency import DependencyManager + + +@patch("proxy_worker.utils.dependency.DependencyManager._get_cx_deps_path", return_value="/mock/cx/site-packages") +@patch("proxy_worker.utils.dependency.DependencyManager._get_cx_working_dir", return_value="/mock/cx") +@patch("proxy_worker.utils.dependency.DependencyManager._get_worker_deps_path", return_value="/mock/worker") +@patch("proxy_worker.utils.dependency.logger") +def test_use_worker_dependencies(mock_logger, mock_worker, mock_cx_dir, mock_cx_deps): + sys.path = ["/mock/cx/site-packages", "/mock/cx", "/original"] + + DependencyManager.initialize() + DependencyManager.use_worker_dependencies() + + assert sys.path[0] == "/mock/worker" + assert "/mock/cx/site-packages" not in sys.path + assert "/mock/cx" not in sys.path + + mock_logger.info.assert_any_call( + 'Applying use_worker_dependencies:' + ' worker_dependencies: %s,' + ' customer_dependencies: %s,' + ' working_directory: %s', + "/mock/worker", "/mock/cx/site-packages", "/mock/cx" + ) + +@patch("proxy_worker.utils.dependency.DependencyManager._get_cx_deps_path", return_value="/mock/cx/site-packages") +@patch("proxy_worker.utils.dependency.DependencyManager._get_worker_deps_path", return_value="/mock/worker") +@patch("proxy_worker.utils.dependency.DependencyManager._get_cx_working_dir", return_value="/mock/cx") +@patch("proxy_worker.utils.dependency.DependencyManager.is_in_linux_consumption", return_value=False) +@patch("proxy_worker.utils.dependency.is_envvar_true", return_value=False) +@patch("proxy_worker.utils.dependency.logger") +def test_prioritize_customer_dependencies(mock_logger, mock_env, mock_linux, mock_cx_dir, mock_worker, mock_cx_deps): + sys.path = ["/mock/worker", "/some/old/path"] + + DependencyManager.initialize() + DependencyManager.prioritize_customer_dependencies("/override/cx") + + assert sys.path[0] == "/mock/cx/site-packages" + assert sys.path[1] == "/mock/worker" + expected_path = os.path.abspath("/override/cx") + assert expected_path in sys.path + + # Relaxed log validation: look for matching prefix + assert any( + "Applying prioritize_customer_dependencies" in str(call[0][0]) + for call in mock_logger.info.call_args_list + ) + + assert any( + "Finished prioritize_customer_dependencies" in str(call[0][0]) + for call in mock_logger.info.call_args_list + ) + From c46540689866acc2129fdcd14ddd0198a59840ec Mon Sep 17 00:00:00 2001 From: Gavin Aguiar Date: Thu, 10 Apr 2025 17:09:05 -0500 Subject: [PATCH 20/42] Updated test_setup --- tests/test_setup.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/test_setup.py b/tests/test_setup.py index 627538a57..811a4cd95 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -184,11 +184,6 @@ def gen_grpc(): # https://github.com/protocolbuffers/protobuf/issues/1491 make_absolute_imports(compiled_files) - if os.path.exists(str(proto_root_dir)): - shutil.rmtree(str(proto_root_dir)) - shutil.copytree(str(built_protos_dir), str(proto_root_dir)) - - def make_absolute_imports(compiled_files): for compiled in compiled_files: with open(compiled, "r+") as f: From 6083d7f66a6f8993196c8ea7bf085636475f948f Mon Sep 17 00:00:00 2001 From: Gavin Aguiar Date: Fri, 11 Apr 2025 10:04:59 -0500 Subject: [PATCH 21/42] Updated test setup to add grpc dir copy --- tests/test_setup.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/test_setup.py b/tests/test_setup.py index 811a4cd95..27f09eae0 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -183,6 +183,25 @@ def gen_grpc(): # Needed to support absolute imports in files. See # https://github.com/protocolbuffers/protobuf/issues/1491 make_absolute_imports(compiled_files) + copy_tree_merge(str(built_protos_dir), str(proto_root_dir)) + +def copy_tree_merge(src, dst): + """ + Recursively copy all files and subdirectories from src to dst, + overwriting files if they already exist. This emulates what + distutils.dir_util.copy_tree did without removing existing directories. + """ + if not os.path.exists(dst): + os.makedirs(dst) + + for item in os.listdir(src): + s = os.path.join(src, item) + d = os.path.join(dst, item) + + if os.path.isdir(s): + copy_tree_merge(s, d) + else: + shutil.copy2(s, d) def make_absolute_imports(compiled_files): for compiled in compiled_files: From be60b0171a159b97129b1d70f8ff9715b4a5a57a Mon Sep 17 00:00:00 2001 From: hallvictoria <59299039+hallvictoria@users.noreply.github.com> Date: Fri, 11 Apr 2025 11:27:56 -0500 Subject: [PATCH 22/42] build: proxy worker build & test setup (#1664) * build: recognize collection_model_binding_data for batch inputs (#1655) * add cmbd * Add * Add * Rm newline * Add tests * Fix cmbd * Fix test * Lint * Rm * Rm * Add back newline * rm ws * Rm list * Rm cmbd from cache * Avoid caching * Keep cmbd check * Add comment * Lint --------- Co-authored-by: Evan Roman Co-authored-by: hallvictoria <59299039+hallvictoria@users.noreply.github.com> * build: update Python Worker Version to 4.36.1 (#1660) Co-authored-by: AzureFunctionsPython * initial changes * Update Python SDK Version to 1.23.0 (#1663) Co-authored-by: AzureFunctionsPython * merges from ADO * merge fixes * merge fixes * merge fixes * merge fixes * don't run 313 unit tests yet * changes for builds --------- Co-authored-by: Evan <66287338+EvanR-Dev@users.noreply.github.com> Co-authored-by: Evan Roman Co-authored-by: AzureFunctionsPython Co-authored-by: AzureFunctionsPython --- azure_functions_worker/protos/.gitignore | 1 - eng/templates/jobs/ci-emulator-tests.yml | 2 + .../official/jobs/build-artifacts.yml | 15 + eng/templates/official/jobs/ci-e2e-tests.yml | 9 + ...oft.Azure.Functions.V4.PythonWorker.nuspec | 5 + pack/scripts/mac_arm64_deps.sh | 8 +- pack/scripts/nix_deps.sh | 8 +- pack/scripts/win_deps.ps1 | 16 +- pack/templates/macos_64_env_gen.yml | 35 + pack/templates/nix_env_gen.yml | 35 + pack/templates/win_env_gen.yml | 34 + proxy_worker/protos/.gitignore | 1 - proxy_worker/protos/_src/.gitignore | 288 +++++++ proxy_worker/protos/_src/LICENSE | 21 + proxy_worker/protos/_src/README.md | 98 +++ .../protos/_src/src/proto/FunctionRpc.proto | 730 ++++++++++++++++++ .../proto/identity/ClaimsIdentityRpc.proto | 26 + .../_src/src/proto/shared/NullableTypes.proto | 30 + pyproject.toml | 17 +- python/proxyV4/worker.py | 1 - python/test/worker.py | 12 +- setup.cfg | 6 + .../http_v2_tests/test_http_v2.py | 7 +- tests/unittests/test_code_quality.py | 2 +- tests/utils/testutils.py | 252 +++--- 25 files changed, 1516 insertions(+), 143 deletions(-) create mode 100644 proxy_worker/protos/_src/.gitignore create mode 100644 proxy_worker/protos/_src/LICENSE create mode 100644 proxy_worker/protos/_src/README.md create mode 100644 proxy_worker/protos/_src/src/proto/FunctionRpc.proto create mode 100644 proxy_worker/protos/_src/src/proto/identity/ClaimsIdentityRpc.proto create mode 100644 proxy_worker/protos/_src/src/proto/shared/NullableTypes.proto diff --git a/azure_functions_worker/protos/.gitignore b/azure_functions_worker/protos/.gitignore index f43e6c214..49d7060ef 100644 --- a/azure_functions_worker/protos/.gitignore +++ b/azure_functions_worker/protos/.gitignore @@ -1,3 +1,2 @@ -/_src *_pb2.py *_pb2_grpc.py diff --git a/eng/templates/jobs/ci-emulator-tests.yml b/eng/templates/jobs/ci-emulator-tests.yml index abc84f394..968585017 100644 --- a/eng/templates/jobs/ci-emulator-tests.yml +++ b/eng/templates/jobs/ci-emulator-tests.yml @@ -21,6 +21,8 @@ jobs: PYTHON_VERSION: '3.11' Python312: PYTHON_VERSION: '3.12' + Python313: + PYTHON_VERSION: '3.13' steps: - task: UsePythonVersion@0 inputs: diff --git a/eng/templates/official/jobs/build-artifacts.yml b/eng/templates/official/jobs/build-artifacts.yml index 631115b4c..bc0c5de1a 100644 --- a/eng/templates/official/jobs/build-artifacts.yml +++ b/eng/templates/official/jobs/build-artifacts.yml @@ -24,6 +24,9 @@ jobs: Python312V4: pythonVersion: '3.12' workerPath: 'python/prodV4/worker.py' + Python313V4: + pythonVersion: '3.13' + workerPath: 'python/proxyV4/worker.py' templateContext: outputParentDirectory: $(Build.ArtifactStagingDirectory) outputs: @@ -62,6 +65,9 @@ jobs: Python312V4: pythonVersion: '3.12' workerPath: 'python/prodV4/worker.py' + Python313V4: + pythonVersion: '3.13' + workerPath: 'python/proxyV4/worker.py' templateContext: outputParentDirectory: $(Build.ArtifactStagingDirectory) outputs: @@ -100,6 +106,9 @@ jobs: Python312V4: pythonVersion: '3.12' workerPath: 'python/prodV4/worker.py' + Python313V4: + pythonVersion: '3.13' + workerPath: 'python/proxyV4/worker.py' templateContext: outputParentDirectory: $(Build.ArtifactStagingDirectory) outputs: @@ -137,6 +146,9 @@ jobs: Python312V4: pythonVersion: '3.12' workerPath: 'python/prodV4/worker.py' + Python313V4: + pythonVersion: '3.13' + workerPath: 'python/proxyV4/worker.py' templateContext: outputParentDirectory: $(Build.ArtifactStagingDirectory) outputs: @@ -168,6 +180,9 @@ jobs: Python312V4: pythonVersion: '3.12' workerPath: 'python/prodV4/worker.py' + Python313V4: + pythonVersion: '3.13' + workerPath: 'python/proxyV4/worker.py' templateContext: outputParentDirectory: $(Build.ArtifactStagingDirectory) outputs: diff --git a/eng/templates/official/jobs/ci-e2e-tests.yml b/eng/templates/official/jobs/ci-e2e-tests.yml index edd898f65..33148bf94 100644 --- a/eng/templates/official/jobs/ci-e2e-tests.yml +++ b/eng/templates/official/jobs/ci-e2e-tests.yml @@ -63,6 +63,15 @@ jobs: SQL_CONNECTION: $(LinuxSqlConnectionString312) EVENTGRID_URI: $(LinuxEventGridTopicUriString312) EVENTGRID_CONNECTION: $(LinuxEventGridConnectionKeyString312) + Python313: + PYTHON_VERSION: '3.13' + STORAGE_CONNECTION: $(LinuxStorageConnectionString312) + COSMOSDB_CONNECTION: $(LinuxCosmosDBConnectionString312) + EVENTHUB_CONNECTION: $(LinuxEventHubConnectionString312) + SERVICEBUS_CONNECTION: $(LinuxServiceBusConnectionString312) + SQL_CONNECTION: $(LinuxSqlConnectionString312) + EVENTGRID_URI: $(LinuxEventGridTopicUriString312) + EVENTGRID_CONNECTION: $(LinuxEventGridConnectionKeyString312) steps: - task: UsePythonVersion@0 inputs: diff --git a/pack/Microsoft.Azure.Functions.V4.PythonWorker.nuspec b/pack/Microsoft.Azure.Functions.V4.PythonWorker.nuspec index b3ce47d0c..98b0c832a 100644 --- a/pack/Microsoft.Azure.Functions.V4.PythonWorker.nuspec +++ b/pack/Microsoft.Azure.Functions.V4.PythonWorker.nuspec @@ -38,6 +38,11 @@ + + + + + diff --git a/pack/scripts/mac_arm64_deps.sh b/pack/scripts/mac_arm64_deps.sh index 2d70bafad..9c08cce46 100644 --- a/pack/scripts/mac_arm64_deps.sh +++ b/pack/scripts/mac_arm64_deps.sh @@ -13,4 +13,10 @@ python -m invoke -c test_setup build-protos cd .. cp .artifactignore "$BUILD_SOURCESDIRECTORY/deps" -cp -r azure_functions_worker/protos "$BUILD_SOURCESDIRECTORY/deps/azure_functions_worker" \ No newline at end of file + +version_minor=$(echo $1 | cut -d '.' -f 2) +if [[ $version_minor -lt 13 ]]; then + cp -r azure_functions_worker/protos "$BUILD_SOURCESDIRECTORY/deps/azure_functions_worker" +else + cp -r proxy_worker/protos "$BUILD_SOURCESDIRECTORY/deps/proxy_worker" +fi \ No newline at end of file diff --git a/pack/scripts/nix_deps.sh b/pack/scripts/nix_deps.sh index 2d70bafad..9c08cce46 100644 --- a/pack/scripts/nix_deps.sh +++ b/pack/scripts/nix_deps.sh @@ -13,4 +13,10 @@ python -m invoke -c test_setup build-protos cd .. cp .artifactignore "$BUILD_SOURCESDIRECTORY/deps" -cp -r azure_functions_worker/protos "$BUILD_SOURCESDIRECTORY/deps/azure_functions_worker" \ No newline at end of file + +version_minor=$(echo $1 | cut -d '.' -f 2) +if [[ $version_minor -lt 13 ]]; then + cp -r azure_functions_worker/protos "$BUILD_SOURCESDIRECTORY/deps/azure_functions_worker" +else + cp -r proxy_worker/protos "$BUILD_SOURCESDIRECTORY/deps/proxy_worker" +fi \ No newline at end of file diff --git a/pack/scripts/win_deps.ps1 b/pack/scripts/win_deps.ps1 index a7be372e7..b4c95203d 100644 --- a/pack/scripts/win_deps.ps1 +++ b/pack/scripts/win_deps.ps1 @@ -1,3 +1,9 @@ +param ( + [string]$pythonVersion +) +$versionParts = $pythonVersion -split '\.' # Splitting by dot +$versionMinor = [int]$versionParts[1] + python -m venv .env .env\Scripts\Activate.ps1 python -m pip install --upgrade pip @@ -5,7 +11,6 @@ python -m pip install --upgrade pip python -m pip install . $depsPath = Join-Path -Path $env:BUILD_SOURCESDIRECTORY -ChildPath "deps" -$protosPath = Join-Path -Path $depsPath -ChildPath "azure_functions_worker/protos" python -m pip install . azure-functions --no-compile --target $depsPath.ToString() @@ -15,4 +20,11 @@ python -m invoke -c test_setup build-protos cd .. Copy-Item -Path ".artifactignore" -Destination $depsPath.ToString() -Copy-Item -Path "azure_functions_worker/protos/*" -Destination $protosPath.ToString() -Recurse -Force + +if ($versionMinor -lt 13) { + $protosPath = Join-Path -Path $depsPath -ChildPath "azure_functions_worker/protos" + Copy-Item -Path "azure_functions_worker/protos/*" -Destination $protosPath.ToString() -Recurse -Force +} else { + $protosPath = Join-Path -Path $depsPath -ChildPath "proxy_worker/protos" + Copy-Item -Path "proxy_worker/protos/*" -Destination $protosPath.ToString() -Recurse -Force +} diff --git a/pack/templates/macos_64_env_gen.yml b/pack/templates/macos_64_env_gen.yml index 90a3578d7..75f33bc5f 100644 --- a/pack/templates/macos_64_env_gen.yml +++ b/pack/templates/macos_64_env_gen.yml @@ -8,10 +8,19 @@ steps: inputs: versionSpec: ${{ parameters.pythonVersion }} addToPath: true +- bash: | + major=$(echo $(pythonVersion) | cut -d. -f1) + minor=$(echo $(pythonVersion) | cut -d. -f2) + echo "##vso[task.setvariable variable=pythonMajor]$major" + echo "##vso[task.setvariable variable=pythonMinor]$minor" + echo $pythonMinor + displayName: 'Parse pythonVersion' - task: ShellScript@2 inputs: disableAutoCwd: true scriptPath: 'pack/scripts/mac_arm64_deps.sh' + args: '${{ parameters.pythonVersion }}' + displayName: 'Install Dependencies' - bash: | pip install pip-audit pip-audit -r requirements.txt @@ -41,4 +50,30 @@ steps: !pkg_resources/** !*.dist-info/** !werkzeug/debug/shared/debugger.js + !proxy_worker/** + targetFolder: '$(Build.ArtifactStagingDirectory)' + condition: in(variables['pythonMinor'], '7', '8', '9', '10', '11', '12') + displayName: 'Copy azure_functions_worker files' +- task: CopyFiles@2 + inputs: + sourceFolder: '$(Build.SourcesDirectory)/deps' + contents: | + ** + !grpc_tools/**/* + !grpcio_tools*/* + !build/** + !docs/** + !pack/** + !python/** + !tests/** + !setuptools*/** + !_distutils_hack/** + !distutils-precedence.pth + !pkg_resources/** + !*.dist-info/** + !werkzeug/debug/shared/debugger.js + !azure_functions_worker/** + !dateutil/** targetFolder: '$(Build.ArtifactStagingDirectory)' + condition: in(variables['pythonMinor'], '13') + displayName: 'Copy proxy_worker files' diff --git a/pack/templates/nix_env_gen.yml b/pack/templates/nix_env_gen.yml index ae3cf4330..db3820153 100644 --- a/pack/templates/nix_env_gen.yml +++ b/pack/templates/nix_env_gen.yml @@ -8,10 +8,19 @@ steps: inputs: versionSpec: ${{ parameters.pythonVersion }} addToPath: true +- bash: | + major=$(echo $(pythonVersion) | cut -d. -f1) + minor=$(echo $(pythonVersion) | cut -d. -f2) + echo "##vso[task.setvariable variable=pythonMajor]$major" + echo "##vso[task.setvariable variable=pythonMinor]$minor" + echo $pythonMinor + displayName: 'Parse pythonVersion' - task: ShellScript@2 inputs: disableAutoCwd: true scriptPath: 'pack/scripts/nix_deps.sh' + args: '${{ parameters.pythonVersion }}' + displayName: 'Install Dependencies' - bash: | pip install pip-audit pip-audit -r requirements.txt @@ -41,4 +50,30 @@ steps: !pkg_resources/** !*.dist-info/** !werkzeug/debug/shared/debugger.js + !proxy_worker/** + targetFolder: '$(Build.ArtifactStagingDirectory)' + condition: in(variables['pythonMinor'], '7', '8', '9', '10', '11', '12') + displayName: 'Copy azure_functions_worker files' +- task: CopyFiles@2 + inputs: + sourceFolder: '$(Build.SourcesDirectory)/deps' + contents: | + ** + !grpc_tools/**/* + !grpcio_tools*/* + !build/** + !docs/** + !pack/** + !python/** + !tests/** + !setuptools*/** + !_distutils_hack/** + !distutils-precedence.pth + !pkg_resources/** + !*.dist-info/** + !werkzeug/debug/shared/debugger.js + !dateutil/** + !azure_functions_worker/** targetFolder: '$(Build.ArtifactStagingDirectory)' + condition: in(variables['pythonMinor'], '13') + displayName: 'Copy proxy_worker files' diff --git a/pack/templates/win_env_gen.yml b/pack/templates/win_env_gen.yml index 2eee3411a..b85bf9f89 100644 --- a/pack/templates/win_env_gen.yml +++ b/pack/templates/win_env_gen.yml @@ -9,9 +9,17 @@ steps: versionSpec: ${{ parameters.pythonVersion }} architecture: ${{ parameters.architecture }} addToPath: true +- bash: | + major=$(echo $(pythonVersion) | cut -d. -f1) + minor=$(echo $(pythonVersion) | cut -d. -f2) + echo "##vso[task.setvariable variable=pythonMajor]$major" + echo "##vso[task.setvariable variable=pythonMinor]$minor" + echo $pythonMinor + displayName: 'Parse pythonVersion' - task: PowerShell@2 inputs: filePath: 'pack\scripts\win_deps.ps1' + arguments: '${{ parameters.pythonVersion }}' - bash: | pip install pip-audit pip-audit -r requirements.txt @@ -41,4 +49,30 @@ steps: !pkg_resources\** !*.dist-info\** !werkzeug\debug\shared\debugger.js + !proxy_worker\** + targetFolder: '$(Build.ArtifactStagingDirectory)' + condition: in(variables['pythonMinor'], '7', '8', '9', '10', '11', '12') + displayName: 'Copy azure_functions_worker files' +- task: CopyFiles@2 + inputs: + sourceFolder: '$(Build.SourcesDirectory)\deps' + contents: | + ** + !grpc_tools\**\* + !grpcio_tools*\* + !build\** + !docs\** + !pack\** + !python\** + !tests\** + !setuptools*\** + !_distutils_hack\** + !distutils-precedence.pth + !pkg_resources\** + !*.dist-info\** + !werkzeug\debug\shared\debugger.js + !dateutil\** + !azure_functions_worker\** targetFolder: '$(Build.ArtifactStagingDirectory)' + condition: in(variables['pythonMinor'], '13') + displayName: 'Copy proxy_worker files' diff --git a/proxy_worker/protos/.gitignore b/proxy_worker/protos/.gitignore index f43e6c214..49d7060ef 100644 --- a/proxy_worker/protos/.gitignore +++ b/proxy_worker/protos/.gitignore @@ -1,3 +1,2 @@ -/_src *_pb2.py *_pb2_grpc.py diff --git a/proxy_worker/protos/_src/.gitignore b/proxy_worker/protos/_src/.gitignore new file mode 100644 index 000000000..940794e60 --- /dev/null +++ b/proxy_worker/protos/_src/.gitignore @@ -0,0 +1,288 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ +**/Properties/launchSettings.json + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# TODO: Comment the next line if you want to checkin your web deploy settings +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Typescript v1 declaration files +typings/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush +.cr/ + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs diff --git a/proxy_worker/protos/_src/LICENSE b/proxy_worker/protos/_src/LICENSE new file mode 100644 index 000000000..21071075c --- /dev/null +++ b/proxy_worker/protos/_src/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. All rights reserved. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/proxy_worker/protos/_src/README.md b/proxy_worker/protos/_src/README.md new file mode 100644 index 000000000..b22f0bb4b --- /dev/null +++ b/proxy_worker/protos/_src/README.md @@ -0,0 +1,98 @@ +# Azure Functions Languge Worker Protobuf + +This repository contains the protobuf definition file which defines the gRPC service which is used between the [Azure Functions Host](https://github.com/Azure/azure-functions-host) and the Azure Functions language workers. This repo is shared across many repos in many languages (for each worker) by using git commands. + +To use this repo in Azure Functions language workers, follow steps below to add this repo as a subtree (*Adding This Repo*). If this repo is already embedded in a language worker repo, follow the steps to update the consumed file (*Pulling Updates*). + +Learn more about Azure Function's projects on the [meta](https://github.com/azure/azure-functions) repo. + +## Adding This Repo + +From within the Azure Functions language worker repo: +1. Define remote branch for cleaner git commands + - `git remote add proto-file https://github.com/azure/azure-functions-language-worker-protobuf.git` + - `git fetch proto-file` +2. Index contents of azure-functions-worker-protobuf to language worker repo + - `git read-tree --prefix= -u proto-file/` +3. Add new path in language worker repo to .gitignore file + - In .gitignore, add path in language worker repo +4. Finalize with commit + - `git commit -m "Added subtree from https://github.com/azure/azure-functions-language-worker-protobuf. Branch: . Commit: "` + - `git push` + +## Pulling Updates + +From within the Azure Functions language worker repo: +1. Define remote branch for cleaner git commands + - `git remote add proto-file https://github.com/azure/azure-functions-language-worker-protobuf.git` + - `git fetch proto-file` +2. Pull a specific release tag + - `git fetch proto-file refs/tags/` + - Example: `git fetch proto-file refs/tags/v1.1.0-protofile` +3. Merge updates + - Merge with an explicit path to subtree: `git merge -X subtree= --squash --allow-unrelated-histories --strategy-option theirs` + - Example: `git merge -X subtree=src/WebJobs.Script.Grpc/azure-functions-language-worker-protobuf --squash v1.1.0-protofile --allow-unrelated-histories --strategy-option theirs` +4. Finalize with commit + - `git commit -m "Updated subtree from https://github.com/azure/azure-functions-language-worker-protobuf. Tag: . Commit: "` + - `git push` + +## Releasing a Language Worker Protobuf version + +1. Draft a release in the GitHub UI + - Be sure to inculde details of the release +2. Create a release version, following semantic versioning guidelines ([semver.org](https://semver.org/)) +3. Tag the version with the pattern: `v..

-protofile` (example: `v1.1.0-protofile`) +3. Merge `dev` to `master` + +## Consuming FunctionRPC.proto +*Note: Update versionNumber before running following commands* + +## CSharp +``` +set NUGET_PATH="%UserProfile%\.nuget\packages" +set GRPC_TOOLS_PATH=%NUGET_PATH%\grpc.tools\\tools\windows_x86 +set PROTO_PATH=.\azure-functions-language-worker-protobuf\src\proto +set PROTO=.\azure-functions-language-worker-protobuf\src\proto\FunctionRpc.proto +set PROTOBUF_TOOLS=%NUGET_PATH%\google.protobuf.tools\\tools +set MSGDIR=.\Messages + +if exist %MSGDIR% rmdir /s /q %MSGDIR% +mkdir %MSGDIR% + +set OUTDIR=%MSGDIR%\DotNet +mkdir %OUTDIR% +%GRPC_TOOLS_PATH%\protoc.exe %PROTO% --csharp_out %OUTDIR% --grpc_out=%OUTDIR% --plugin=protoc-gen-grpc=%GRPC_TOOLS_PATH%\grpc_csharp_plugin.exe --proto_path=%PROTO_PATH% --proto_path=%PROTOBUF_TOOLS% +``` +## JavaScript +In package.json, add to the build script the following commands to build .js files and to build .ts files. Use and install npm package `protobufjs`. + +Generate JavaScript files: +``` +pbjs -t json-module -w commonjs -o azure-functions-language-worker-protobuf/src/rpc.js azure-functions-language-worker-protobuf/src/proto/FunctionRpc.proto +``` +Generate TypeScript files: +``` +pbjs -t static-module azure-functions-language-worker-protobuf/src/proto/FunctionRpc.proto -o azure-functions-language-worker-protobuf/src/rpc_static.js && pbts -o azure-functions-language-worker-protobuf/src/rpc.d.ts azure-functions-language-worker-protobuf/src/rpc_static.js +``` + +## Java +Maven plugin : [protobuf-maven-plugin](https://www.xolstice.org/protobuf-maven-plugin/) +In pom.xml add following under configuration for this plugin +${basedir}//azure-functions-language-worker-protobuf/src/proto + +## Python +--TODO + +## Contributing + +This project welcomes contributions and suggestions. Most contributions require you to agree to a +Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us +the rights to use your contribution. For details, visit https://cla.microsoft.com. + +When you submit a pull request, a CLA-bot will automatically determine whether you need to provide +a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions +provided by the bot. You will only need to do this once across all repos using our CLA. + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). +For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or +contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. diff --git a/proxy_worker/protos/_src/src/proto/FunctionRpc.proto b/proxy_worker/protos/_src/src/proto/FunctionRpc.proto new file mode 100644 index 000000000..f48bc7bbe --- /dev/null +++ b/proxy_worker/protos/_src/src/proto/FunctionRpc.proto @@ -0,0 +1,730 @@ +syntax = "proto3"; +// protobuf vscode extension: https://marketplace.visualstudio.com/items?itemName=zxh404.vscode-proto3 + +option java_multiple_files = true; +option java_package = "com.microsoft.azure.functions.rpc.messages"; +option java_outer_classname = "FunctionProto"; +option csharp_namespace = "Microsoft.Azure.WebJobs.Script.Grpc.Messages"; +option go_package ="github.com/Azure/azure-functions-go-worker/internal/rpc"; + +package AzureFunctionsRpcMessages; + +import "google/protobuf/duration.proto"; +import "identity/ClaimsIdentityRpc.proto"; +import "shared/NullableTypes.proto"; + +// Interface exported by the server. +service FunctionRpc { + rpc EventStream (stream StreamingMessage) returns (stream StreamingMessage) {} +} + +message StreamingMessage { + // Used to identify message between host and worker + string request_id = 1; + + // Payload of the message + oneof content { + + // Worker initiates stream + StartStream start_stream = 20; + + // Host sends capabilities/init data to worker + WorkerInitRequest worker_init_request = 17; + // Worker responds after initializing with its capabilities & status + WorkerInitResponse worker_init_response = 16; + + // MESSAGE NOT USED + // Worker periodically sends empty heartbeat message to host + WorkerHeartbeat worker_heartbeat = 15; + + // Host sends terminate message to worker. + // Worker terminates if it can, otherwise host terminates after a grace period + WorkerTerminate worker_terminate = 14; + + // Host periodically sends status request to the worker + WorkerStatusRequest worker_status_request = 12; + WorkerStatusResponse worker_status_response = 13; + + // On file change event, host sends notification to worker + FileChangeEventRequest file_change_event_request = 6; + + // Worker requests a desired action (restart worker, reload function) + WorkerActionResponse worker_action_response = 7; + + // Host sends required metadata to worker to load function + FunctionLoadRequest function_load_request = 8; + // Worker responds after loading with the load result + FunctionLoadResponse function_load_response = 9; + + // Host requests a given invocation + InvocationRequest invocation_request = 4; + + // Worker responds to a given invocation + InvocationResponse invocation_response = 5; + + // Host sends cancel message to attempt to cancel an invocation. + // If an invocation is cancelled, host will receive an invocation response with status cancelled. + InvocationCancel invocation_cancel = 21; + + // Worker logs a message back to the host + RpcLog rpc_log = 2; + + FunctionEnvironmentReloadRequest function_environment_reload_request = 25; + + FunctionEnvironmentReloadResponse function_environment_reload_response = 26; + + // Ask the worker to close any open shared memory resources for a given invocation + CloseSharedMemoryResourcesRequest close_shared_memory_resources_request = 27; + CloseSharedMemoryResourcesResponse close_shared_memory_resources_response = 28; + + // Worker indexing message types + FunctionsMetadataRequest functions_metadata_request = 29; + FunctionMetadataResponse function_metadata_response = 30; + + // Host sends required metadata to worker to load functions + FunctionLoadRequestCollection function_load_request_collection = 31; + + // Host gets the list of function load responses + FunctionLoadResponseCollection function_load_response_collection = 32; + + // Host sends required metadata to worker to warmup the worker + WorkerWarmupRequest worker_warmup_request = 33; + + // Worker responds after warming up with the warmup result + WorkerWarmupResponse worker_warmup_response = 34; + + } +} + +// Process.Start required info +// connection details +// protocol type +// protocol version + +// Worker sends the host information identifying itself +message StartStream { + // id of the worker + string worker_id = 2; +} + +// Host requests the worker to initialize itself +message WorkerInitRequest { + // version of the host sending init request + string host_version = 1; + + // A map of host supported features/capabilities + map capabilities = 2; + + // inform worker of supported categories and their levels + // i.e. Worker = Verbose, Function.MyFunc = None + map log_categories = 3; + + // Full path of worker.config.json location + string worker_directory = 4; + + // base directory for function app + string function_app_directory = 5; +} + +// Worker responds with the result of initializing itself +message WorkerInitResponse { + // PROPERTY NOT USED + // TODO: Remove from protobuf during next breaking change release + string worker_version = 1; + + // A map of worker supported features/capabilities + map capabilities = 2; + + // Status of the response + StatusResult result = 3; + + // Worker metadata captured for telemetry purposes + WorkerMetadata worker_metadata = 4; +} + +message WorkerMetadata { + // The runtime/stack name + string runtime_name = 1; + + // The version of the runtime/stack + string runtime_version = 2; + + // The version of the worker + string worker_version = 3; + + // The worker bitness/architecture + string worker_bitness = 4; + + // Optional additional custom properties + map custom_properties = 5; +} + +// Used by the host to determine success/failure/cancellation +message StatusResult { + // Indicates Failure/Success/Cancelled + enum Status { + Failure = 0; + Success = 1; + Cancelled = 2; + } + + // Status for the given result + Status status = 4; + + // Specific message about the result + string result = 1; + + // Exception message (if exists) for the status + RpcException exception = 2; + + // Captured logs or relevant details can use the logs property + repeated RpcLog logs = 3; +} + +// MESSAGE NOT USED +// TODO: Remove from protobuf during next breaking change release +message WorkerHeartbeat {} + +// Warning before killing the process after grace_period +// Worker self terminates ..no response on this +message WorkerTerminate { + google.protobuf.Duration grace_period = 1; +} + +// Host notifies worker of file content change +message FileChangeEventRequest { + // Types of File change operations (See link for more info: https://msdn.microsoft.com/en-us/library/t6xf43e0(v=vs.110).aspx) + enum Type { + Unknown = 0; + Created = 1; + Deleted = 2; + Changed = 4; + Renamed = 8; + All = 15; + } + + // type for this event + Type type = 1; + + // full file path for the file change notification + string full_path = 2; + + // Name of the function affected + string name = 3; +} + +// Indicates whether worker reloaded successfully or needs a restart +message WorkerActionResponse { + // indicates whether a restart is needed, or reload successfully + enum Action { + Restart = 0; + Reload = 1; + } + + // action for this response + Action action = 1; + + // text reason for the response + string reason = 2; +} + +// Used by the host to determine worker health +message WorkerStatusRequest { +} + +// Worker responds with status message +// TODO: Add any worker relevant status to response +message WorkerStatusResponse { +} + +message FunctionEnvironmentReloadRequest { + // Environment variables from the current process + map environment_variables = 1; + // Current directory of function app + string function_app_directory = 2; +} + +message FunctionEnvironmentReloadResponse { + // After specialization, worker sends capabilities & metadata. + // Worker metadata captured for telemetry purposes + WorkerMetadata worker_metadata = 1; + + // A map of worker supported features/capabilities + map capabilities = 2; + + // Status of the response + StatusResult result = 3; +} + +// Tell the out-of-proc worker to close any shared memory maps it allocated for given invocation +message CloseSharedMemoryResourcesRequest { + repeated string map_names = 1; +} + +// Response from the worker indicating which of the shared memory maps have been successfully closed and which have not been closed +// The key (string) is the map name and the value (bool) is true if it was closed, false if not +message CloseSharedMemoryResourcesResponse { + map close_map_results = 1; +} + +// Host tells the worker to load a list of Functions +message FunctionLoadRequestCollection { + repeated FunctionLoadRequest function_load_requests = 1; +} + +// Host gets the list of function load responses +message FunctionLoadResponseCollection { + repeated FunctionLoadResponse function_load_responses = 1; +} + +// Load request of a single Function +message FunctionLoadRequest { + // unique function identifier (avoid name collisions, facilitate reload case) + string function_id = 1; + + // Metadata for the request + RpcFunctionMetadata metadata = 2; + + // A flag indicating if managed dependency is enabled or not + bool managed_dependency_enabled = 3; +} + +// Worker tells host result of reload +message FunctionLoadResponse { + // unique function identifier + string function_id = 1; + + // Result of load operation + StatusResult result = 2; + // TODO: return type expected? + + // Result of load operation + bool is_dependency_downloaded = 3; +} + +// Information on how a Function should be loaded and its bindings +message RpcFunctionMetadata { + // TODO: do we want the host's name - the language worker might do a better job of assignment than the host + string name = 4; + + // base directory for the Function + string directory = 1; + + // Script file specified + string script_file = 2; + + // Entry point specified + string entry_point = 3; + + // Bindings info + map bindings = 6; + + // Is set to true for proxy + bool is_proxy = 7; + + // Function indexing status + StatusResult status = 8; + + // Function language + string language = 9; + + // Raw binding info + repeated string raw_bindings = 10; + + // unique function identifier (avoid name collisions, facilitate reload case) + string function_id = 13; + + // A flag indicating if managed dependency is enabled or not + bool managed_dependency_enabled = 14; + + // The optional function execution retry strategy to use on invocation failures. + RpcRetryOptions retry_options = 15; + + // Properties for function metadata + // They're usually specific to a worker and largely passed along to the controller API for use + // outside the host + map properties = 16; +} + +// Host tells worker it is ready to receive metadata +message FunctionsMetadataRequest { + // base directory for function app + string function_app_directory = 1; +} + +// Worker sends function metadata back to host +message FunctionMetadataResponse { + // list of function indexing responses + repeated RpcFunctionMetadata function_metadata_results = 1; + + // status of overall metadata request + StatusResult result = 2; + + // if set to true then host will perform indexing + bool use_default_metadata_indexing = 3; +} + +// Host requests worker to invoke a Function +message InvocationRequest { + // Unique id for each invocation + string invocation_id = 1; + + // Unique id for each Function + string function_id = 2; + + // Input bindings (include trigger) + repeated ParameterBinding input_data = 3; + + // binding metadata from trigger + map trigger_metadata = 4; + + // Populates activityId, tracestate and tags from host + RpcTraceContext trace_context = 5; + + // Current retry context + RetryContext retry_context = 6; +} + +// Host sends ActivityId, traceStateString and Tags from host +message RpcTraceContext { + // This corresponds to Activity.Current?.Id + string trace_parent = 1; + + // This corresponds to Activity.Current?.TraceStateString + string trace_state = 2; + + // This corresponds to Activity.Current?.Tags + map attributes = 3; +} + +// Host sends retry context for a function invocation +message RetryContext { + // Current retry count + int32 retry_count = 1; + + // Max retry count + int32 max_retry_count = 2; + + // Exception that caused the retry + RpcException exception = 3; +} + +// Host requests worker to cancel invocation +message InvocationCancel { + // Unique id for invocation + string invocation_id = 2; + + // PROPERTY NOT USED + google.protobuf.Duration grace_period = 1; +} + +// Worker responds with status of Invocation +message InvocationResponse { + // Unique id for invocation + string invocation_id = 1; + + // Output binding data + repeated ParameterBinding output_data = 2; + + // data returned from Function (for $return and triggers with return support) + TypedData return_value = 4; + + // Status of the invocation (success/failure/canceled) + StatusResult result = 3; +} + +message WorkerWarmupRequest { + // Full path of worker.config.json location + string worker_directory = 1; +} + +message WorkerWarmupResponse { + StatusResult result = 1; +} + +// Used to encapsulate data which could be a variety of types +message TypedData { + oneof data { + string string = 1; + string json = 2; + bytes bytes = 3; + bytes stream = 4; + RpcHttp http = 5; + sint64 int = 6; + double double = 7; + CollectionBytes collection_bytes = 8; + CollectionString collection_string = 9; + CollectionDouble collection_double = 10; + CollectionSInt64 collection_sint64 = 11; + ModelBindingData model_binding_data = 12; + CollectionModelBindingData collection_model_binding_data = 13; + } +} + +// Specify which type of data is contained in the shared memory region being read +enum RpcDataType { + unknown = 0; + string = 1; + json = 2; + bytes = 3; + stream = 4; + http = 5; + int = 6; + double = 7; + collection_bytes = 8; + collection_string = 9; + collection_double = 10; + collection_sint64 = 11; +} + +// Used to provide metadata about shared memory region to read data from +message RpcSharedMemory { + // Name of the shared memory map containing data + string name = 1; + // Offset in the shared memory map to start reading data from + int64 offset = 2; + // Number of bytes to read (starting from the offset) + int64 count = 3; + // Final type to which the read data (in bytes) is to be interpreted as + RpcDataType type = 4; +} + +// Used to encapsulate collection string +message CollectionString { + repeated string string = 1; +} + +// Used to encapsulate collection bytes +message CollectionBytes { + repeated bytes bytes = 1; +} + +// Used to encapsulate collection double +message CollectionDouble { + repeated double double = 1; +} + +// Used to encapsulate collection sint64 +message CollectionSInt64 { + repeated sint64 sint64 = 1; +} + +// Used to describe a given binding on invocation +message ParameterBinding { + // Name for the binding + string name = 1; + + oneof rpc_data { + // Data for the binding + TypedData data = 2; + + // Metadata about the shared memory region to read data from + RpcSharedMemory rpc_shared_memory = 3; + } +} + +// Used to describe a given binding on load +message BindingInfo { + // Indicates whether it is an input or output binding (or a fancy inout binding) + enum Direction { + in = 0; + out = 1; + inout = 2; + } + + // Indicates the type of the data for the binding + enum DataType { + undefined = 0; + string = 1; + binary = 2; + stream = 3; + } + + // Type of binding (e.g. HttpTrigger) + string type = 2; + + // Direction of the given binding + Direction direction = 3; + + DataType data_type = 4; + + // Properties for binding metadata + map properties = 5; +} + +// Used to send logs back to the Host +message RpcLog { + // Matching ILogger semantics + // https://github.com/aspnet/Logging/blob/9506ccc3f3491488fe88010ef8b9eb64594abf95/src/Microsoft.Extensions.Logging/Logger.cs + // Level for the Log + enum Level { + Trace = 0; + Debug = 1; + Information = 2; + Warning = 3; + Error = 4; + Critical = 5; + None = 6; + } + + // Category of the log. Defaults to User if not specified. + enum RpcLogCategory { + User = 0; + System = 1; + CustomMetric = 2; + } + + // Unique id for invocation (if exists) + string invocation_id = 1; + + // TOD: This should be an enum + // Category for the log (startup, load, invocation, etc.) + string category = 2; + + // Level for the given log message + Level level = 3; + + // Message for the given log + string message = 4; + + // Id for the even associated with this log (if exists) + string event_id = 5; + + // Exception (if exists) + RpcException exception = 6; + + // json serialized property bag + string properties = 7; + + // Category of the log. Either user(default), system, or custom metric. + RpcLogCategory log_category = 8; + + // strongly-typed (ish) property bag + map propertiesMap = 9; +} + +// Encapsulates an Exception +message RpcException { + // Source of the exception + string source = 3; + + // Stack trace for the exception + string stack_trace = 1; + + // Textual message describing the exception + string message = 2; + + // Worker specifies whether exception is a user exception, + // for purpose of application insights logging. Defaults to false. + bool is_user_exception = 4; + + // Type of exception. If it's a user exception, the type is passed along to app insights. + // Otherwise, it's ignored for now. + string type = 5; +} + +// Http cookie type. Note that only name and value are used for Http requests +message RpcHttpCookie { + // Enum that lets servers require that a cookie shouldn't be sent with cross-site requests + enum SameSite { + None = 0; + Lax = 1; + Strict = 2; + ExplicitNone = 3; + } + + // Cookie name + string name = 1; + + // Cookie value + string value = 2; + + // Specifies allowed hosts to receive the cookie + NullableString domain = 3; + + // Specifies URL path that must exist in the requested URL + NullableString path = 4; + + // Sets the cookie to expire at a specific date instead of when the client closes. + // It is generally recommended that you use "Max-Age" over "Expires". + NullableTimestamp expires = 5; + + // Sets the cookie to only be sent with an encrypted request + NullableBool secure = 6; + + // Sets the cookie to be inaccessible to JavaScript's Document.cookie API + NullableBool http_only = 7; + + // Allows servers to assert that a cookie ought not to be sent along with cross-site requests + SameSite same_site = 8; + + // Number of seconds until the cookie expires. A zero or negative number will expire the cookie immediately. + NullableDouble max_age = 9; +} + +// TODO - solidify this or remove it +message RpcHttp { + string method = 1; + string url = 2; + map headers = 3; + TypedData body = 4; + map params = 10; + string status_code = 12; + map query = 15; + bool enable_content_negotiation= 16; + TypedData rawBody = 17; + repeated RpcClaimsIdentity identities = 18; + repeated RpcHttpCookie cookies = 19; + map nullable_headers = 20; + map nullable_params = 21; + map nullable_query = 22; +} + +// Message representing Microsoft.Azure.WebJobs.ParameterBindingData +// Used for hydrating SDK-type bindings in out-of-proc workers +message ModelBindingData +{ + // The version of the binding data content + string version = 1; + + // The extension source of the binding data + string source = 2; + + // The content type of the binding data content + string content_type = 3; + + // The binding data content + bytes content = 4; +} + +// Used to encapsulate collection model_binding_data +message CollectionModelBindingData { + repeated ModelBindingData model_binding_data = 1; +} + +// Retry policy which the worker sends the host when the worker indexes +// a function. +message RpcRetryOptions +{ + // The retry strategy to use. Valid values are fixed delay or exponential backoff. + enum RetryStrategy + { + exponential_backoff = 0; + fixed_delay = 1; + } + + // The maximum number of retries allowed per function execution. + // -1 means to retry indefinitely. + int32 max_retry_count = 2; + + // The delay that's used between retries when you're using a fixed delay strategy. + google.protobuf.Duration delay_interval = 3; + + // The minimum retry delay when you're using an exponential backoff strategy + google.protobuf.Duration minimum_interval = 4; + + // The maximum retry delay when you're using an exponential backoff strategy + google.protobuf.Duration maximum_interval = 5; + + RetryStrategy retry_strategy = 6; +} \ No newline at end of file diff --git a/proxy_worker/protos/_src/src/proto/identity/ClaimsIdentityRpc.proto b/proxy_worker/protos/_src/src/proto/identity/ClaimsIdentityRpc.proto new file mode 100644 index 000000000..c3945bb8a --- /dev/null +++ b/proxy_worker/protos/_src/src/proto/identity/ClaimsIdentityRpc.proto @@ -0,0 +1,26 @@ +syntax = "proto3"; +// protobuf vscode extension: https://marketplace.visualstudio.com/items?itemName=zxh404.vscode-proto3 + +option java_package = "com.microsoft.azure.functions.rpc.messages"; + +import "shared/NullableTypes.proto"; + +// Light-weight representation of a .NET System.Security.Claims.ClaimsIdentity object. +// This is the same serialization as found in EasyAuth, and needs to be kept in sync with +// its ClaimsIdentitySlim definition, as seen in the WebJobs extension: +// https://github.com/Azure/azure-webjobs-sdk-extensions/blob/dev/src/WebJobs.Extensions.Http/ClaimsIdentitySlim.cs +message RpcClaimsIdentity { + NullableString authentication_type = 1; + NullableString name_claim_type = 2; + NullableString role_claim_type = 3; + repeated RpcClaim claims = 4; +} + +// Light-weight representation of a .NET System.Security.Claims.Claim object. +// This is the same serialization as found in EasyAuth, and needs to be kept in sync with +// its ClaimSlim definition, as seen in the WebJobs extension: +// https://github.com/Azure/azure-webjobs-sdk-extensions/blob/dev/src/WebJobs.Extensions.Http/ClaimSlim.cs +message RpcClaim { + string value = 1; + string type = 2; +} diff --git a/proxy_worker/protos/_src/src/proto/shared/NullableTypes.proto b/proxy_worker/protos/_src/src/proto/shared/NullableTypes.proto new file mode 100644 index 000000000..4fb476502 --- /dev/null +++ b/proxy_worker/protos/_src/src/proto/shared/NullableTypes.proto @@ -0,0 +1,30 @@ +syntax = "proto3"; +// protobuf vscode extension: https://marketplace.visualstudio.com/items?itemName=zxh404.vscode-proto3 + +option java_package = "com.microsoft.azure.functions.rpc.messages"; + +import "google/protobuf/timestamp.proto"; + +message NullableString { + oneof string { + string value = 1; + } +} + +message NullableDouble { + oneof double { + double value = 1; + } +} + +message NullableBool { + oneof bool { + bool value = 1; + } +} + +message NullableTimestamp { + oneof timestamp { + google.protobuf.Timestamp value = 1; + } +} diff --git a/pyproject.toml b/pyproject.toml index 109e1c34b..ce8a5063b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,15 +26,20 @@ classifiers = [ "Intended Audience :: Developers" ] dependencies = [ - "azure-functions==1.23.0b3", - "python-dateutil ~=2.9.0", + "azure-functions==1.23.0", + "python-dateutil~=2.9.0", "protobuf~=3.19.3; python_version == '3.7'", - "protobuf~=5.29.0; python_version >= '3.8'", + "protobuf~=4.25.3; python_version >= '3.8' and python_version < '3.13'", + "protobuf~=5.29.0; python_version >= '3.13'", "grpcio-tools~=1.43.0; python_version == '3.7'", - "grpcio-tools~=1.70.0; python_version >= '3.8'", + "grpcio-tools~=1.59.0; python_version >= '3.8' and python_version < '3.13'", + "grpcio-tools~=1.70.0; python_version >= '3.13'", "grpcio~=1.43.0; python_version == '3.7'", - "grpcio~=1.70.0; python_version >= '3.8'", - "azurefunctions-extensions-base; python_version >= '3.8'" + "grpcio ~=1.59.0; python_version >= '3.8' and python_version < '3.13'", + "grpcio~=1.70.0; python_version >= '3.13'", + "azurefunctions-extensions-base; python_version >= '3.8'", + "test-worker==1.0.0a38; python_version >= '3.13'", + "test-worker-v1==1.0.0a11; python_version >= '3.13'" ] [project.urls] diff --git a/python/proxyV4/worker.py b/python/proxyV4/worker.py index dce5d51e6..2f899f37e 100644 --- a/python/proxyV4/worker.py +++ b/python/proxyV4/worker.py @@ -2,7 +2,6 @@ import pathlib import sys - PKGS_PATH = "/home/site/wwwroot/.python_packages" PKGS = "lib/site-packages" diff --git a/python/test/worker.py b/python/test/worker.py index a15d160ed..95790083f 100644 --- a/python/test/worker.py +++ b/python/test/worker.py @@ -1,7 +1,6 @@ import sys import os -from azure_functions_worker import main -from proxy_worker import start_worker + # Azure environment variables AZURE_WEBJOBS_SCRIPT_ROOT = "AzureWebJobsScriptRoot" @@ -16,7 +15,10 @@ def add_script_root_to_sys_path(): if __name__ == '__main__': add_script_root_to_sys_path() - if sys.version_info.minor >= 13: - start_worker.start() - else: + minor_version = sys.version_info[1] + if minor_version < 13: + from azure_functions_worker import main main.main() + else: + from proxy_worker import start_worker + start_worker.start() diff --git a/setup.cfg b/setup.cfg index 6f5a7fb98..5dde99ef5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -18,3 +18,9 @@ ignore_errors = True [mypy-azure_functions_worker._thirdparty.typing_inspect] ignore_errors = True + +[mypy-proxy_worker.protos.*] +ignore_errors = True + +[mypy-proxy_worker._thirdparty.typing_inspect] +ignore_errors = True \ No newline at end of file diff --git a/tests/extension_tests/http_v2_tests/test_http_v2.py b/tests/extension_tests/http_v2_tests/test_http_v2.py index 8c1d5b48e..514633743 100644 --- a/tests/extension_tests/http_v2_tests/test_http_v2.py +++ b/tests/extension_tests/http_v2_tests/test_http_v2.py @@ -8,11 +8,16 @@ import requests from tests.utils import testutils -from azure_functions_worker.utils.common import is_envvar_true from tests.utils.constants import CONSUMPTION_DOCKER_TEST, DEDICATED_DOCKER_TEST +# This app setting is only present for Python < 3.13 from azure_functions_worker.constants import PYTHON_ENABLE_INIT_INDEXING +if sys.version_info.minor < 13: + from azure_functions_worker.utils.common import is_envvar_true +else: + from proxy_worker.utils.common import is_envvar_true + REQUEST_TIMEOUT_SEC = 5 diff --git a/tests/unittests/test_code_quality.py b/tests/unittests/test_code_quality.py index 54d1cc725..45f7bda47 100644 --- a/tests/unittests/test_code_quality.py +++ b/tests/unittests/test_code_quality.py @@ -17,7 +17,7 @@ def test_mypy(self): try: subprocess.run( - [sys.executable, '-m', 'mypy', '-m', 'azure_functions_worker'], + [sys.executable, '-m', 'mypy', '-m', 'azure_functions_worker', 'proxy_worker'], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, diff --git a/tests/utils/testutils.py b/tests/utils/testutils.py index c04b134c5..360fca2e5 100644 --- a/tests/utils/testutils.py +++ b/tests/utils/testutils.py @@ -50,18 +50,23 @@ WebHostDedicated, ) -from azure_functions_worker import dispatcher, protos -from azure_functions_worker.bindings.shared_memory_data_transfer import ( - FileAccessorFactory, -) -from azure_functions_worker.bindings.shared_memory_data_transfer import ( - SharedMemoryConstants as consts, -) -from azure_functions_worker.constants import ( - FUNCTIONS_WORKER_SHARED_MEMORY_DATA_TRANSFER_ENABLED, - UNIX_SHARED_MEMORY_DIRECTORIES, -) -from azure_functions_worker.utils.common import get_app_setting, is_envvar_true +if sys.version_info.minor < 13: + from azure_functions_worker import dispatcher, protos + from azure_functions_worker.bindings.shared_memory_data_transfer import ( + FileAccessorFactory, + ) + from azure_functions_worker.bindings.shared_memory_data_transfer import ( + SharedMemoryConstants as consts, + ) + from azure_functions_worker.constants import ( + FUNCTIONS_WORKER_SHARED_MEMORY_DATA_TRANSFER_ENABLED, + UNIX_SHARED_MEMORY_DIRECTORIES, + ) + from azure_functions_worker.utils.common import get_app_setting, is_envvar_true +else: + from proxy_worker import dispatcher, protos + from proxy_worker.utils.common import is_envvar_true + from proxy_worker.utils.app_settings import get_app_setting TESTS_ROOT = PROJECT_ROOT / 'tests' E2E_TESTS_FOLDER = pathlib.Path('endtoend') @@ -320,125 +325,126 @@ def _run_test(self, test, *args, **kwargs): if test_exception is not None: raise test_exception - -class SharedMemoryTestCase(unittest.TestCase): - """ - For tests involving shared memory data transfer usage. - """ - - def setUp(self): - self.was_shmem_env_true = is_envvar_true( - FUNCTIONS_WORKER_SHARED_MEMORY_DATA_TRANSFER_ENABLED) - os.environ.update( - {FUNCTIONS_WORKER_SHARED_MEMORY_DATA_TRANSFER_ENABLED: '1'}) - - os_name = platform.system() - if os_name == 'Darwin': - # If an existing AppSetting is specified, save it so it can be - # restored later - self.was_shmem_dirs = get_app_setting( - UNIX_SHARED_MEMORY_DIRECTORIES - ) - self._setUpDarwin() - elif os_name == 'Linux': - self._setUpLinux() - self.file_accessor = FileAccessorFactory.create_file_accessor() - - def tearDown(self): - os_name = platform.system() - if os_name == 'Darwin': - self._tearDownDarwin() - if self.was_shmem_dirs is not None: - # If an AppSetting was set before the tests ran, restore it back - os.environ.update( - {UNIX_SHARED_MEMORY_DIRECTORIES: self.was_shmem_dirs}) - elif os_name == 'Linux': - self._tearDownLinux() - - if not self.was_shmem_env_true: - os.environ.update( - {FUNCTIONS_WORKER_SHARED_MEMORY_DATA_TRANSFER_ENABLED: '0'}) - - def get_new_mem_map_name(self): - return str(uuid.uuid4()) - - def get_random_bytes(self, num_bytes): - return bytearray(random.getrandbits(8) for _ in range(num_bytes)) - - def get_random_string(self, num_chars): - return ''.join(random.choices(string.ascii_uppercase + string.digits, - k=num_chars)) - - def is_valid_uuid(self, uuid_to_test: str, version: int = 4) -> bool: +# This is not supported in 3.13+ +if sys.version_info.minor < 13: + class SharedMemoryTestCase(unittest.TestCase): """ - Check if uuid_to_test is a valid UUID. - Reference: https://stackoverflow.com/a/33245493/3132415 + For tests involving shared memory data transfer usage. """ - try: - uuid_obj = uuid.UUID(uuid_to_test, version=version) - except ValueError: - return False - return str(uuid_obj) == uuid_to_test - def _createSharedMemoryDirectories(self, directories): - for temp_dir in directories: - temp_dir_path = os.path.join(temp_dir, consts.UNIX_TEMP_DIR_SUFFIX) - if not os.path.exists(temp_dir_path): - os.makedirs(temp_dir_path) + def setUp(self): + self.was_shmem_env_true = is_envvar_true( + FUNCTIONS_WORKER_SHARED_MEMORY_DATA_TRANSFER_ENABLED) + os.environ.update( + {FUNCTIONS_WORKER_SHARED_MEMORY_DATA_TRANSFER_ENABLED: '1'}) + + os_name = platform.system() + if os_name == 'Darwin': + # If an existing AppSetting is specified, save it so it can be + # restored later + self.was_shmem_dirs = get_app_setting( + UNIX_SHARED_MEMORY_DIRECTORIES + ) + self._setUpDarwin() + elif os_name == 'Linux': + self._setUpLinux() + self.file_accessor = FileAccessorFactory.create_file_accessor() + + def tearDown(self): + os_name = platform.system() + if os_name == 'Darwin': + self._tearDownDarwin() + if self.was_shmem_dirs is not None: + # If an AppSetting was set before the tests ran, restore it back + os.environ.update( + {UNIX_SHARED_MEMORY_DIRECTORIES: self.was_shmem_dirs}) + elif os_name == 'Linux': + self._tearDownLinux() + + if not self.was_shmem_env_true: + os.environ.update( + {FUNCTIONS_WORKER_SHARED_MEMORY_DATA_TRANSFER_ENABLED: '0'}) - def _deleteSharedMemoryDirectories(self, directories): - for temp_dir in directories: - temp_dir_path = os.path.join(temp_dir, consts.UNIX_TEMP_DIR_SUFFIX) - shutil.rmtree(temp_dir_path) + def get_new_mem_map_name(self): + return str(uuid.uuid4()) - def _setUpLinux(self): - self._createSharedMemoryDirectories(consts.UNIX_TEMP_DIRS) + def get_random_bytes(self, num_bytes): + return bytearray(random.getrandbits(8) for _ in range(num_bytes)) - def _tearDownLinux(self): - self._deleteSharedMemoryDirectories(consts.UNIX_TEMP_DIRS) + def get_random_string(self, num_chars): + return ''.join(random.choices(string.ascii_uppercase + string.digits, + k=num_chars)) - def _setUpDarwin(self): - """ - Create a RAM disk on macOS. - Ref: https://stackoverflow.com/a/2033417/3132415 - """ - size_in_mb = consts.MAX_BYTES_FOR_SHARED_MEM_TRANSFER / (1024 * 1024) - size = 2048 * size_in_mb - # The following command returns the name of the created disk - cmd = ['hdiutil', 'attach', '-nomount', f'ram://{size}'] - result = subprocess.run(cmd, stdout=subprocess.PIPE) - if result.returncode != 0: - raise IOError(f'Cannot create ram disk with command: {cmd} - ' - f'{result.stdout} - {result.stderr}') - disk_name = result.stdout.strip().decode() - # We create a volume on the disk created above and mount it - volume_name = 'shm' - cmd = ['diskutil', 'eraseVolume', 'HFS+', volume_name, disk_name] - result = subprocess.run(cmd, stdout=subprocess.PIPE) - if result.returncode != 0: - raise IOError(f'Cannot create volume with command: {cmd} - ' - f'{result.stdout} - {result.stderr}') - directory = f'/Volumes/{volume_name}' - self.created_directories = [directory] - # Create directories in the volume for shared memory maps - self._createSharedMemoryDirectories(self.created_directories) - # Override the AppSetting for the duration of this test so the - # FileAccessorUnix can use these directories for creating memory maps - os.environ.update( - {UNIX_SHARED_MEMORY_DIRECTORIES: ','.join(self.created_directories)} - ) + def is_valid_uuid(self, uuid_to_test: str, version: int = 4) -> bool: + """ + Check if uuid_to_test is a valid UUID. + Reference: https://stackoverflow.com/a/33245493/3132415 + """ + try: + uuid_obj = uuid.UUID(uuid_to_test, version=version) + except ValueError: + return False + return str(uuid_obj) == uuid_to_test + + def _createSharedMemoryDirectories(self, directories): + for temp_dir in directories: + temp_dir_path = os.path.join(temp_dir, consts.UNIX_TEMP_DIR_SUFFIX) + if not os.path.exists(temp_dir_path): + os.makedirs(temp_dir_path) + + def _deleteSharedMemoryDirectories(self, directories): + for temp_dir in directories: + temp_dir_path = os.path.join(temp_dir, consts.UNIX_TEMP_DIR_SUFFIX) + shutil.rmtree(temp_dir_path) + + def _setUpLinux(self): + self._createSharedMemoryDirectories(consts.UNIX_TEMP_DIRS) + + def _tearDownLinux(self): + self._deleteSharedMemoryDirectories(consts.UNIX_TEMP_DIRS) + + def _setUpDarwin(self): + """ + Create a RAM disk on macOS. + Ref: https://stackoverflow.com/a/2033417/3132415 + """ + size_in_mb = consts.MAX_BYTES_FOR_SHARED_MEM_TRANSFER / (1024 * 1024) + size = 2048 * size_in_mb + # The following command returns the name of the created disk + cmd = ['hdiutil', 'attach', '-nomount', f'ram://{size}'] + result = subprocess.run(cmd, stdout=subprocess.PIPE) + if result.returncode != 0: + raise IOError(f'Cannot create ram disk with command: {cmd} - ' + f'{result.stdout} - {result.stderr}') + disk_name = result.stdout.strip().decode() + # We create a volume on the disk created above and mount it + volume_name = 'shm' + cmd = ['diskutil', 'eraseVolume', 'HFS+', volume_name, disk_name] + result = subprocess.run(cmd, stdout=subprocess.PIPE) + if result.returncode != 0: + raise IOError(f'Cannot create volume with command: {cmd} - ' + f'{result.stdout} - {result.stderr}') + directory = f'/Volumes/{volume_name}' + self.created_directories = [directory] + # Create directories in the volume for shared memory maps + self._createSharedMemoryDirectories(self.created_directories) + # Override the AppSetting for the duration of this test so the + # FileAccessorUnix can use these directories for creating memory maps + os.environ.update( + {UNIX_SHARED_MEMORY_DIRECTORIES: ','.join(self.created_directories)} + ) - def _tearDownDarwin(self): - # Delete the directories containing shared memory maps - self._deleteSharedMemoryDirectories(self.created_directories) - # Unmount the volume used for shared memory maps - volume_name = 'shm' - cmd = f"find /Volumes -type d -name '{volume_name}*' -print0 " \ - "| xargs -0 umount -f" - result = subprocess.run(cmd, stdout=subprocess.PIPE, shell=True) - if result.returncode != 0: - raise IOError(f'Cannot delete volume with command: {cmd} - ' - f'{result.stdout} - {result.stderr}') + def _tearDownDarwin(self): + # Delete the directories containing shared memory maps + self._deleteSharedMemoryDirectories(self.created_directories) + # Unmount the volume used for shared memory maps + volume_name = 'shm' + cmd = f"find /Volumes -type d -name '{volume_name}*' -print0 " \ + "| xargs -0 umount -f" + result = subprocess.run(cmd, stdout=subprocess.PIPE, shell=True) + if result.returncode != 0: + raise IOError(f'Cannot delete volume with command: {cmd} - ' + f'{result.stdout} - {result.stderr}') class _MockWebHostServicer(protos.FunctionRpcServicer): From 2992d42717af60c21007898c0ccdbb473a6a2639 Mon Sep 17 00:00:00 2001 From: Gavin Aguiar Date: Fri, 11 Apr 2025 11:33:49 -0500 Subject: [PATCH 23/42] Merging changes --- proxy_worker/protos/__init__.py | 43 ------------------------ proxy_worker/protos/identity/__init__.py | 0 proxy_worker/protos/shared/__init__.py | 0 proxy_worker/utils/dependency.py | 4 +-- python/test/worker.config.json | 2 +- 5 files changed, 2 insertions(+), 47 deletions(-) delete mode 100644 proxy_worker/protos/__init__.py delete mode 100644 proxy_worker/protos/identity/__init__.py delete mode 100644 proxy_worker/protos/shared/__init__.py diff --git a/proxy_worker/protos/__init__.py b/proxy_worker/protos/__init__.py deleted file mode 100644 index e9c4f2397..000000000 --- a/proxy_worker/protos/__init__.py +++ /dev/null @@ -1,43 +0,0 @@ -from .FunctionRpc_pb2_grpc import ( # NoQA - FunctionRpcStub, - FunctionRpcServicer, - add_FunctionRpcServicer_to_server) - -from .FunctionRpc_pb2 import ( # NoQA - StreamingMessage, - StartStream, - WorkerInitRequest, - WorkerInitResponse, - RpcFunctionMetadata, - FunctionLoadRequest, - FunctionLoadResponse, - FunctionEnvironmentReloadRequest, - FunctionEnvironmentReloadResponse, - InvocationRequest, - InvocationResponse, - WorkerHeartbeat, - WorkerStatusRequest, - WorkerStatusResponse, - BindingInfo, - StatusResult, - RpcException, - ParameterBinding, - TypedData, - RpcHttp, - RpcHttpCookie, - RpcLog, - RpcSharedMemory, - RpcDataType, - CloseSharedMemoryResourcesRequest, - CloseSharedMemoryResourcesResponse, - FunctionsMetadataRequest, - FunctionMetadataResponse, - WorkerMetadata, - RpcRetryOptions) - -from .shared.NullableTypes_pb2 import ( - NullableString, - NullableBool, - NullableDouble, - NullableTimestamp -) diff --git a/proxy_worker/protos/identity/__init__.py b/proxy_worker/protos/identity/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/proxy_worker/protos/shared/__init__.py b/proxy_worker/protos/shared/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/proxy_worker/utils/dependency.py b/proxy_worker/utils/dependency.py index e80613815..48c130bb5 100644 --- a/proxy_worker/utils/dependency.py +++ b/proxy_worker/utils/dependency.py @@ -1,14 +1,12 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -import importlib.util -import inspect import os import re import sys from types import ModuleType from typing import List, Optional -from .common import is_envvar_true, is_true_like +from .common import is_envvar_true from .constants import AZURE_WEBJOBS_SCRIPT_ROOT, CONTAINER_NAME from ..logging import logger diff --git a/python/test/worker.config.json b/python/test/worker.config.json index f778e45f3..67a13f222 100644 --- a/python/test/worker.config.json +++ b/python/test/worker.config.json @@ -2,7 +2,7 @@ "description":{ "language":"python", "extensions":[".py"], - "defaultExecutablePath":"python", + "defaultExecutablePath":"G:\\Repos\\python\\azure-functions-python-worker\\.venv313\\Scripts\\python.exe", "defaultWorkerPath":"worker.py", "workerIndexing": "true", "arguments": ["-X no_debug_ranges"] From dc94eb1071aadeb8f364b0e96c0cee6972185515 Mon Sep 17 00:00:00 2001 From: Gavin Aguiar Date: Fri, 11 Apr 2025 11:48:18 -0500 Subject: [PATCH 24/42] linting fixes --- .flake8 | 2 +- proxy_worker/dispatcher.py | 33 ++++---- proxy_worker/start_worker.py | 2 +- proxy_worker/utils/__init__.py | 2 +- proxy_worker/utils/app_settings.py | 1 + proxy_worker/utils/common.py | 1 + proxy_worker/utils/dependency.py | 16 ++-- proxy_worker/version.py | 2 +- python/proxyV4/worker.py | 1 - python/test/worker.config.json | 2 +- tests/test_setup.py | 8 +- .../unittests/proxy_worker/test_dependency.py | 26 +++--- .../unittests/proxy_worker/test_dispatcher.py | 81 ++++++++++++------- tests/unittests/test_code_quality.py | 3 +- tests/utils/testutils.py | 15 ++-- 15 files changed, 115 insertions(+), 80 deletions(-) diff --git a/.flake8 b/.flake8 index 94a2f1926..142c1bea0 100644 --- a/.flake8 +++ b/.flake8 @@ -6,7 +6,7 @@ ignore = W503,E402,E731 exclude = .git, __pycache__, build, dist, .eggs, .github, .local, docs/, - Samples, azure_functions_worker/protos/, + Samples, azure_functions_worker/protos/, proxy_worker/protos/, azure_functions_worker/_thirdparty/typing_inspect.py, tests/unittests/test_typing_inspect.py, tests/unittests/broken_functions/syntax_error/main.py, diff --git a/proxy_worker/dispatcher.py b/proxy_worker/dispatcher.py index 50e375d08..e0d7e23a9 100644 --- a/proxy_worker/dispatcher.py +++ b/proxy_worker/dispatcher.py @@ -24,7 +24,8 @@ ) from proxy_worker.utils.app_settings import get_app_setting from proxy_worker.utils.common import is_envvar_true -from proxy_worker.utils.constants import PYTHON_ENABLE_DEBUG_LOGGING, PYTHON_THREADPOOL_THREAD_COUNT +from proxy_worker.utils.constants import PYTHON_ENABLE_DEBUG_LOGGING, \ + PYTHON_THREADPOOL_THREAD_COUNT from proxy_worker.version import VERSION from .utils.dependency import DependencyManager @@ -387,14 +388,14 @@ async def _handle__worker_init_request(self, request): _library_worker = azure_functions_worker_v2 logger.debug("V2 worker import succeeded: %s", _library_worker.__file__) except ImportError: - logger.warning("Error importing V2 library: %s",traceback.format_exc()) + logger.warning("Error importing V2 library: %s", traceback.format_exc()) else: try: import azure_functions_worker_v1 # NoQA _library_worker = azure_functions_worker_v1 logger.debug("V1 worker import succeeded: %s", _library_worker.__file__) except ImportError: - logger.warning("Error importing V1 library: %s",traceback.format_exc()) + logger.warning("Error importing V1 library: %s", traceback.format_exc()) init_request = WorkerRequest(name="WorkerInitRequest", request=request, @@ -406,7 +407,6 @@ async def _handle__worker_init_request(self, request): request_id=self.request_id, worker_init_response=init_response) - async def _handle__function_environment_reload_request(self, request): logger.info('Received FunctionEnvironmentReloadRequest, ' 'request ID: %s, ' @@ -426,27 +426,28 @@ async def _handle__function_environment_reload_request(self, request): try: import azure_functions_worker_v2 # NoQA _library_worker = azure_functions_worker_v2 - logger.debug("V2 worker import succeeded: %s",_library_worker.__file__) + logger.debug("V2 worker import succeeded: %s", _library_worker.__file__) except ImportError: - logger.warning("Error importing V2 library: %s",traceback.format_exc()) + logger.warning("Error importing V2 library: %s", traceback.format_exc()) else: try: import azure_functions_worker_v1 # NoQA _library_worker = azure_functions_worker_v1 - logger.debug("V1 worker import succeeded: %s",_library_worker.__file__) + logger.debug("V1 worker import succeeded: %s", _library_worker.__file__) except ImportError: - logger.warning("Error importing V1 library: %s",traceback.format_exc()) + logger.warning("Error importing V1 library: %s", traceback.format_exc()) - env_reload_request = WorkerRequest(name="FunctionEnvironmentReloadRequest", request=request, + env_reload_request = WorkerRequest(name="FunctionEnvironmentReloadRequest", + request=request, properties={"protos": protos, "host": self._host}) - env_reload_response = await _library_worker.function_environment_reload_request(env_reload_request) + env_reload_response = await _library_worker.function_environment_reload_request( + env_reload_request) return protos.StreamingMessage( request_id=self.request_id, function_environment_reload_response=env_reload_response) - async def _handle__worker_status_request(self, request): # Logging is not necessary in this request since the response is used # for host to judge scale decisions of out-of-proc languages. @@ -455,7 +456,6 @@ async def _handle__worker_status_request(self, request): request_id=request.request_id, worker_status_response=protos.WorkerStatusResponse()) - async def _handle__functions_metadata_request(self, request): logger.info( 'Received WorkerMetadataRequest, request ID %s, ' @@ -463,7 +463,8 @@ async def _handle__functions_metadata_request(self, request): self.request_id, self.worker_id) metadata_request = WorkerRequest(name="WorkerMetadataRequest", request=request) - metadata_response = await _library_worker.functions_metadata_request(metadata_request) + metadata_response = await _library_worker.functions_metadata_request( + metadata_request) return protos.StreamingMessage( request_id=request.request_id, @@ -498,8 +499,10 @@ async def _handle__invocation_request(self, request): self.request_id, function_id, invocation_id, self.worker_id) invocation_request = WorkerRequest(name="WorkerInvRequest", request=request, - properties={"threadpool": self._sync_call_tp}) - invocation_response = await _library_worker.invocation_request(invocation_request) + properties={ + "threadpool": self._sync_call_tp}) + invocation_response = await _library_worker.invocation_request( + invocation_request) return protos.StreamingMessage( request_id=self.request_id, diff --git a/proxy_worker/start_worker.py b/proxy_worker/start_worker.py index 6a0aaca12..c60d60561 100644 --- a/proxy_worker/start_worker.py +++ b/proxy_worker/start_worker.py @@ -54,7 +54,7 @@ def start(): args = parse_args() logging.setup(log_level=args.log_level, log_destination=args.log_to) - logger.info("Args: %s" , args) + logger.info("Args: %s", args) logger.info('Starting Azure Functions Python Worker.') logger.info('Worker ID: %s, Request ID: %s, Host Address: %s:%s', args.worker_id, args.request_id, args.host, args.port) diff --git a/proxy_worker/utils/__init__.py b/proxy_worker/utils/__init__.py index 6fcf0de49..5b7f7a925 100644 --- a/proxy_worker/utils/__init__.py +++ b/proxy_worker/utils/__init__.py @@ -1,2 +1,2 @@ # Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. \ No newline at end of file +# Licensed under the MIT License. diff --git a/proxy_worker/utils/app_settings.py b/proxy_worker/utils/app_settings.py index af6fdcb39..73e9fcc85 100644 --- a/proxy_worker/utils/app_settings.py +++ b/proxy_worker/utils/app_settings.py @@ -4,6 +4,7 @@ import os from typing import Callable, Optional + def get_app_setting( setting: str, default_value: Optional[str] = None, diff --git a/proxy_worker/utils/common.py b/proxy_worker/utils/common.py index f371e97d0..ea5f0e022 100644 --- a/proxy_worker/utils/common.py +++ b/proxy_worker/utils/common.py @@ -3,6 +3,7 @@ import os + def is_true_like(setting: str) -> bool: if setting is None: return False diff --git a/proxy_worker/utils/dependency.py b/proxy_worker/utils/dependency.py index 48c130bb5..0df0e4cd2 100644 --- a/proxy_worker/utils/dependency.py +++ b/proxy_worker/utils/dependency.py @@ -67,7 +67,7 @@ def is_in_linux_consumption(cls): @classmethod def should_load_cx_dependencies(cls): """ - Customer dependencies should be loaded when + Customer dependencies should be loaded when 1) App is a dedicated app 2) App is linux consumption but not in placeholder mode. This can happen when the worker restarts for any reason @@ -98,7 +98,8 @@ def use_worker_dependencies(cls): cls._remove_from_sys_path(cls.cx_deps_path) cls._remove_from_sys_path(cls.cx_working_dir) cls._add_to_sys_path(cls.worker_deps_path, True) - logger.info('Start using worker dependencies %s. Sys.path: %s', cls.worker_deps_path, sys.path) + logger.info('Start using worker dependencies %s. Sys.path: %s', + cls.worker_deps_path, sys.path) @classmethod def prioritize_customer_dependencies(cls, cx_working_dir=None): @@ -129,14 +130,15 @@ def prioritize_customer_dependencies(cls, cx_working_dir=None): # Try to get the latest customer's dependency path cx_deps_path: str = cls._get_cx_deps_path() - + if not cx_deps_path: - cx_deps_path = cls.cx_deps_path - + cx_deps_path = cls.cx_deps_path + logger.info( 'Applying prioritize_customer_dependencies: ' 'worker_dependencies_path: %s, customer_dependencies_path: %s, ' - 'working_directory: %s, Linux Consumption: %s, Placeholder: %s, sys.path: %s', + 'working_directory: %s, Linux Consumption: %s, Placeholder: %s, ' + 'sys.path: %s', cls.worker_deps_path, cx_deps_path, working_directory, DependencyManager.is_in_linux_consumption(), is_envvar_true("WEBSITE_PLACEHOLDER_MODE"), sys.path) @@ -146,10 +148,8 @@ def prioritize_customer_dependencies(cls, cx_working_dir=None): cls._add_to_sys_path(cls.cx_deps_path, True) cls._add_to_sys_path(working_directory, False) - logger.info(f'Finished prioritize_customer_dependencies: {sys.path}') - @classmethod def _add_to_sys_path(cls, path: str, add_to_first: bool): """This will ensure no duplicated path are added into sys.path and diff --git a/proxy_worker/version.py b/proxy_worker/version.py index a56028d71..3277f64c2 100644 --- a/proxy_worker/version.py +++ b/proxy_worker/version.py @@ -1 +1 @@ -VERSION="1.0.0" \ No newline at end of file +VERSION = "1.0.0" diff --git a/python/proxyV4/worker.py b/python/proxyV4/worker.py index 2f899f37e..52326792c 100644 --- a/python/proxyV4/worker.py +++ b/python/proxyV4/worker.py @@ -11,7 +11,6 @@ AZURE_WEBJOBS_SCRIPT_ROOT = "AzureWebJobsScriptRoot" - def is_azure_environment(): """Check if the function app is running on the cloud""" return (AZURE_CONTAINER_NAME in os.environ diff --git a/python/test/worker.config.json b/python/test/worker.config.json index 67a13f222..f778e45f3 100644 --- a/python/test/worker.config.json +++ b/python/test/worker.config.json @@ -2,7 +2,7 @@ "description":{ "language":"python", "extensions":[".py"], - "defaultExecutablePath":"G:\\Repos\\python\\azure-functions-python-worker\\.venv313\\Scripts\\python.exe", + "defaultExecutablePath":"python", "defaultWorkerPath":"worker.py", "workerIndexing": "true", "arguments": ["-X no_debug_ranges"] diff --git a/tests/test_setup.py b/tests/test_setup.py index 27f09eae0..9b92446a6 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -112,8 +112,8 @@ def compile_webhost(webhost_dir): subprocess.run( [ "dotnet", "build", "WebJobs.Script.sln", - "/m:1", # Disable parallel MSBuild - "/nodeReuse:false", # Prevent MSBuild node reuse + "/m:1", # Disable parallel MSBuild + "/nodeReuse:false", # Prevent MSBuild node reuse f"--property:OutputPath={webhost_dir}/bin", # Set output folder "/p:TreatWarningsAsErrors=false" ], @@ -185,6 +185,7 @@ def gen_grpc(): make_absolute_imports(compiled_files) copy_tree_merge(str(built_protos_dir), str(proto_root_dir)) + def copy_tree_merge(src, dst): """ Recursively copy all files and subdirectories from src to dst, @@ -203,6 +204,7 @@ def copy_tree_merge(src, dst): else: shutil.copy2(s, d) + def make_absolute_imports(compiled_files): for compiled in compiled_files: with open(compiled, "r+") as f: @@ -213,7 +215,7 @@ def make_absolute_imports(compiled_files): # from azure_functions_worker.protos import xxx_pb2 as.. p1 = re.sub( r"\nimport (.*?_pb2)", - fr"\nfrom {WORKER_DIR}.protos import \g<1>", + fr"\nfrom {WORKER_DIR}.protos import \g<1>", content, ) # Convert lines of the form: diff --git a/tests/unittests/proxy_worker/test_dependency.py b/tests/unittests/proxy_worker/test_dependency.py index 2d7aab150..cea4c5af0 100644 --- a/tests/unittests/proxy_worker/test_dependency.py +++ b/tests/unittests/proxy_worker/test_dependency.py @@ -5,9 +5,12 @@ from proxy_worker.utils.dependency import DependencyManager -@patch("proxy_worker.utils.dependency.DependencyManager._get_cx_deps_path", return_value="/mock/cx/site-packages") -@patch("proxy_worker.utils.dependency.DependencyManager._get_cx_working_dir", return_value="/mock/cx") -@patch("proxy_worker.utils.dependency.DependencyManager._get_worker_deps_path", return_value="/mock/worker") +@patch("proxy_worker.utils.dependency.DependencyManager._get_cx_deps_path", + return_value="/mock/cx/site-packages") +@patch("proxy_worker.utils.dependency.DependencyManager._get_cx_working_dir", + return_value="/mock/cx") +@patch("proxy_worker.utils.dependency.DependencyManager._get_worker_deps_path", + return_value="/mock/worker") @patch("proxy_worker.utils.dependency.logger") def test_use_worker_dependencies(mock_logger, mock_worker, mock_cx_dir, mock_cx_deps): sys.path = ["/mock/cx/site-packages", "/mock/cx", "/original"] @@ -27,13 +30,19 @@ def test_use_worker_dependencies(mock_logger, mock_worker, mock_cx_dir, mock_cx_ "/mock/worker", "/mock/cx/site-packages", "/mock/cx" ) -@patch("proxy_worker.utils.dependency.DependencyManager._get_cx_deps_path", return_value="/mock/cx/site-packages") -@patch("proxy_worker.utils.dependency.DependencyManager._get_worker_deps_path", return_value="/mock/worker") -@patch("proxy_worker.utils.dependency.DependencyManager._get_cx_working_dir", return_value="/mock/cx") -@patch("proxy_worker.utils.dependency.DependencyManager.is_in_linux_consumption", return_value=False) + +@patch("proxy_worker.utils.dependency.DependencyManager._get_cx_deps_path", + return_value="/mock/cx/site-packages") +@patch("proxy_worker.utils.dependency.DependencyManager._get_worker_deps_path", + return_value="/mock/worker") +@patch("proxy_worker.utils.dependency.DependencyManager._get_cx_working_dir", + return_value="/mock/cx") +@patch("proxy_worker.utils.dependency.DependencyManager.is_in_linux_consumption", + return_value=False) @patch("proxy_worker.utils.dependency.is_envvar_true", return_value=False) @patch("proxy_worker.utils.dependency.logger") -def test_prioritize_customer_dependencies(mock_logger, mock_env, mock_linux, mock_cx_dir, mock_worker, mock_cx_deps): +def test_prioritize_customer_dependencies(mock_logger, mock_env, mock_linux, + mock_cx_dir, mock_worker, mock_cx_deps): sys.path = ["/mock/worker", "/some/old/path"] DependencyManager.initialize() @@ -54,4 +63,3 @@ def test_prioritize_customer_dependencies(mock_logger, mock_env, mock_linux, moc "Finished prioritize_customer_dependencies" in str(call[0][0]) for call in mock_logger.info.call_args_list ) - diff --git a/tests/unittests/proxy_worker/test_dispatcher.py b/tests/unittests/proxy_worker/test_dispatcher.py index fc3a1b933..f0780b2f8 100644 --- a/tests/unittests/proxy_worker/test_dispatcher.py +++ b/tests/unittests/proxy_worker/test_dispatcher.py @@ -45,7 +45,8 @@ def test_dispatcher_initialization(self, mock_thread, mock_queue): @patch("proxy_worker.dispatcher.protos.StreamingMessage") @patch("proxy_worker.dispatcher.protos.RpcLog") @patch("proxy_worker.dispatcher.is_system_log_category") - def test_on_logging_levels_and_categories(self, mock_is_system, mock_rpc_log, mock_streaming_message): + def test_on_logging_levels_and_categories(self, mock_is_system, mock_rpc_log, + mock_streaming_message): loop = Mock() dispatcher = Dispatcher(loop, "localhost", 5000, "worker", "req", 5.0) @@ -76,22 +77,26 @@ def test_on_logging_levels_and_categories(self, mock_is_system, mock_rpc_log, mo def fake_import(name, globals=None, locals=None, fromlist=(), level=0): mock_module = types.SimpleNamespace(__file__=f"{name}.py") mock_module.worker_init_request = AsyncMock(return_value="fake_response") - mock_module.function_environment_reload_request = AsyncMock(return_value="mocked_env_reload_response") + mock_module.function_environment_reload_request = AsyncMock( + return_value="mocked_env_reload_response") if name in ["azure_functions_worker_v2", "azure_functions_worker_v1"]: return mock_module return builtins.__import__(name, globals, locals, fromlist, level) - -@patch("proxy_worker.dispatcher.DependencyManager.should_load_cx_dependencies", return_value=True) +@patch("proxy_worker.dispatcher.DependencyManager.should_load_cx_dependencies", + return_value=True) @patch("proxy_worker.dispatcher.DependencyManager.prioritize_customer_dependencies") @patch("proxy_worker.dispatcher.logger") -@patch("proxy_worker.dispatcher.os.path.exists", side_effect=lambda p: p.endswith("function_app.py")) +@patch("proxy_worker.dispatcher.os.path.exists", + side_effect=lambda p: p.endswith("function_app.py")) @patch("builtins.__import__", side_effect=fake_import) -@patch("proxy_worker.dispatcher.protos.StreamingMessage", return_value="mocked_streaming_response") +@patch("proxy_worker.dispatcher.protos.StreamingMessage", + return_value="mocked_streaming_response") @pytest.mark.asyncio async def test_worker_init_v2_import( - mock_streaming, mock_import, mock_exists, mock_logger, mock_prioritize, mock_should_load + mock_streaming, mock_import, mock_exists, mock_logger, mock_prioritize, + mock_should_load ): dispatcher = Dispatcher(asyncio.get_event_loop(), "localhost", 7071, "worker123", "req789", 5.0) @@ -104,15 +109,18 @@ async def test_worker_init_v2_import( mock_logger.debug.assert_any_call("V2 worker import succeeded: %s", ANY) -@patch("proxy_worker.dispatcher.DependencyManager.should_load_cx_dependencies", return_value=True) +@patch("proxy_worker.dispatcher.DependencyManager.should_load_cx_dependencies", + return_value=True) @patch("proxy_worker.dispatcher.DependencyManager.prioritize_customer_dependencies") @patch("proxy_worker.dispatcher.logger") @patch("proxy_worker.dispatcher.os.path.exists", side_effect=lambda p: False) @patch("builtins.__import__", side_effect=fake_import) -@patch("proxy_worker.dispatcher.protos.StreamingMessage", return_value="mocked_streaming_response") +@patch("proxy_worker.dispatcher.protos.StreamingMessage", + return_value="mocked_streaming_response") @pytest.mark.asyncio async def test_worker_init_fallback_to_v1( - mock_streaming, mock_import, mock_exists, mock_logger, mock_prioritize, mock_should_load + mock_streaming, mock_import, mock_exists, mock_logger, mock_prioritize, + mock_should_load ): dispatcher = Dispatcher(asyncio.get_event_loop(), "localhost", 7071, "worker123", "req789", 5.0) @@ -124,33 +132,39 @@ async def test_worker_init_fallback_to_v1( assert result == "mocked_streaming_response" mock_logger.debug.assert_any_call("V1 worker import succeeded: %s", ANY) + @patch("proxy_worker.dispatcher.DependencyManager.prioritize_customer_dependencies") @patch("proxy_worker.dispatcher.logger") -@patch("proxy_worker.dispatcher.os.path.exists", side_effect=lambda p: p.endswith("function_app.py")) +@patch("proxy_worker.dispatcher.os.path.exists", + side_effect=lambda p: p.endswith("function_app.py")) @patch("builtins.__import__", side_effect=fake_import) -@patch("proxy_worker.dispatcher.protos.StreamingMessage", return_value="mocked_reload_response") +@patch("proxy_worker.dispatcher.protos.StreamingMessage", + return_value="mocked_reload_response") @pytest.mark.asyncio async def test_function_environment_reload_v2_import( - mock_streaming, mock_import, mock_exists, mock_logger, mock_prioritize + mock_streaming, mock_import, mock_exists, mock_logger, mock_prioritize ): - dispatcher = Dispatcher(asyncio.get_event_loop(), "localhost", 7071, "worker123", - "req789", 5.0) + dispatcher = Dispatcher(asyncio.get_event_loop(), "localhost", 7071, + "worker123", "req789", 5.0) request = MagicMock() - request.function_environment_reload_request.function_app_directory = "/home/site/wwwroot" + request.function_environment_reload_request.function_app_directory = \ + "/home/site/wwwroot" result = await dispatcher._handle__function_environment_reload_request(request) assert result == "mocked_reload_response" mock_logger.debug.assert_any_call("V2 worker import succeeded: %s", ANY) + @patch("proxy_worker.dispatcher.DependencyManager.prioritize_customer_dependencies") @patch("proxy_worker.dispatcher.logger") @patch("proxy_worker.dispatcher.os.path.exists", side_effect=lambda p: False) @patch("builtins.__import__", side_effect=fake_import) -@patch("proxy_worker.dispatcher.protos.StreamingMessage", return_value="mocked_reload_response") +@patch("proxy_worker.dispatcher.protos.StreamingMessage", + return_value="mocked_reload_response") @pytest.mark.asyncio async def test_function_environment_reload_fallback_to_v1( - mock_streaming, mock_import, mock_exists, mock_logger, mock_prioritize + mock_streaming, mock_import, mock_exists, mock_logger, mock_prioritize ): dispatcher = Dispatcher(asyncio.get_event_loop(), "localhost", 7071, "worker123", "req789", 5.0) @@ -160,12 +174,15 @@ async def test_function_environment_reload_fallback_to_v1( result = await dispatcher._handle__function_environment_reload_request(request) assert result == "mocked_reload_response" - mock_logger.debug.assert_any_call("V1 worker import succeeded: %s", "azure_functions_worker_v1.py") + mock_logger.debug.assert_any_call("V1 worker import succeeded: %s", + "azure_functions_worker_v1.py") @patch("proxy_worker.dispatcher._library_worker", - new=MagicMock(functions_metadata_request=AsyncMock(return_value="mocked_meta_resp"))) -@patch("proxy_worker.dispatcher.protos.StreamingMessage", return_value="mocked_response") + new=MagicMock( + functions_metadata_request=AsyncMock(return_value="mocked_meta_resp"))) +@patch("proxy_worker.dispatcher.protos.StreamingMessage", + return_value="mocked_response") @patch("proxy_worker.dispatcher.logger") @pytest.mark.asyncio async def test_handle_functions_metadata_request(mock_logger, mock_streaming): @@ -183,10 +200,11 @@ async def test_handle_functions_metadata_request(mock_logger, mock_streaming): ) - @patch("proxy_worker.dispatcher._library_worker", - new=MagicMock(function_load_request=AsyncMock(return_value="mocked_load_response"))) -@patch("proxy_worker.dispatcher.protos.StreamingMessage", return_value="mocked_stream_response") + new=MagicMock( + function_load_request=AsyncMock(return_value="mocked_load_response"))) +@patch("proxy_worker.dispatcher.protos.StreamingMessage", + return_value="mocked_stream_response") @patch("proxy_worker.dispatcher.logger") @pytest.mark.asyncio async def test_handle_function_load_request(mock_logger, mock_streaming): @@ -202,14 +220,16 @@ async def test_handle_function_load_request(mock_logger, mock_streaming): assert result == "mocked_stream_response" mock_logger.info.assert_called_with( - 'Received WorkerLoadRequest, request ID %s, function_id: %s,function_name: %s, worker_id: %s', - "req789", "func123", "hello_function", "worker123" + 'Received WorkerLoadRequest, request ID %s, function_id: %s,function_name: %s, ' + 'worker_id: %s', "req789", "func123", "hello_function", "worker123" ) @patch("proxy_worker.dispatcher._library_worker", - new=MagicMock(invocation_request=AsyncMock(return_value="mocked_invoc_response"))) -@patch("proxy_worker.dispatcher.protos.StreamingMessage", return_value="mocked_streaming_response") + new=MagicMock( + invocation_request=AsyncMock(return_value="mocked_invoc_response"))) +@patch("proxy_worker.dispatcher.protos.StreamingMessage", + return_value="mocked_streaming_response") @patch("proxy_worker.dispatcher.logger") @pytest.mark.asyncio async def test_handle_invocation_request(mock_logger, mock_streaming): @@ -225,6 +245,7 @@ async def test_handle_invocation_request(mock_logger, mock_streaming): assert result == "mocked_streaming_response" mock_logger.info.assert_called_with( - 'Received FunctionInvocationRequest, request ID %s, function_id: %s,invocation_id: %s, worker_id: %s', + 'Received FunctionInvocationRequest, request ID %s, function_id: %s,' + 'invocation_id: %s, worker_id: %s', "req789", "func123", "inv123", "worker123" - ) \ No newline at end of file + ) diff --git a/tests/unittests/test_code_quality.py b/tests/unittests/test_code_quality.py index 45f7bda47..c50088e9d 100644 --- a/tests/unittests/test_code_quality.py +++ b/tests/unittests/test_code_quality.py @@ -17,7 +17,8 @@ def test_mypy(self): try: subprocess.run( - [sys.executable, '-m', 'mypy', '-m', 'azure_functions_worker', 'proxy_worker'], + [sys.executable, '-m', 'mypy', '-m', 'azure_functions_worker', + 'proxy_worker'], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, diff --git a/tests/utils/testutils.py b/tests/utils/testutils.py index 360fca2e5..9e16460f2 100644 --- a/tests/utils/testutils.py +++ b/tests/utils/testutils.py @@ -76,9 +76,7 @@ EMULATOR_TESTS_FOLDER = pathlib.Path('emulator_tests') EXTENSION_TESTS_FOLDER = pathlib.Path('extension_tests') WEBHOST_DLL = "Microsoft.Azure.WebJobs.Script.WebHost.dll" -DEFAULT_WEBHOST_DLL_PATH = ( - PROJECT_ROOT / 'build' / 'webhost' / 'bin' / WEBHOST_DLL -) +DEFAULT_WEBHOST_DLL_PATH = (PROJECT_ROOT / 'build' / 'webhost' / 'bin' / WEBHOST_DLL) EXTENSIONS_PATH = PROJECT_ROOT / 'build' / 'extensions' / 'bin' FUNCS_PATH = TESTS_ROOT / UNIT_TESTS_FOLDER / 'http_functions' WORKER_PATH = PROJECT_ROOT / 'python' / 'test' @@ -325,6 +323,7 @@ def _run_test(self, test, *args, **kwargs): if test_exception is not None: raise test_exception + # This is not supported in 3.13+ if sys.version_info.minor < 13: class SharedMemoryTestCase(unittest.TestCase): @@ -373,7 +372,7 @@ def get_random_bytes(self, num_bytes): def get_random_string(self, num_chars): return ''.join(random.choices(string.ascii_uppercase + string.digits, - k=num_chars)) + k=num_chars)) def is_valid_uuid(self, uuid_to_test: str, version: int = 4) -> bool: """ @@ -415,7 +414,7 @@ def _setUpDarwin(self): result = subprocess.run(cmd, stdout=subprocess.PIPE) if result.returncode != 0: raise IOError(f'Cannot create ram disk with command: {cmd} - ' - f'{result.stdout} - {result.stderr}') + f'{result.stdout} - {result.stderr}') disk_name = result.stdout.strip().decode() # We create a volume on the disk created above and mount it volume_name = 'shm' @@ -423,7 +422,7 @@ def _setUpDarwin(self): result = subprocess.run(cmd, stdout=subprocess.PIPE) if result.returncode != 0: raise IOError(f'Cannot create volume with command: {cmd} - ' - f'{result.stdout} - {result.stderr}') + f'{result.stdout} - {result.stderr}') directory = f'/Volumes/{volume_name}' self.created_directories = [directory] # Create directories in the volume for shared memory maps @@ -440,11 +439,11 @@ def _tearDownDarwin(self): # Unmount the volume used for shared memory maps volume_name = 'shm' cmd = f"find /Volumes -type d -name '{volume_name}*' -print0 " \ - "| xargs -0 umount -f" + "| xargs -0 umount -f" result = subprocess.run(cmd, stdout=subprocess.PIPE, shell=True) if result.returncode != 0: raise IOError(f'Cannot delete volume with command: {cmd} - ' - f'{result.stdout} - {result.stderr}') + f'{result.stdout} - {result.stderr}') class _MockWebHostServicer(protos.FunctionRpcServicer): From 66175ce169ac7747a97e90b8922c5ea00e658d3b Mon Sep 17 00:00:00 2001 From: Gavin Aguiar Date: Fri, 11 Apr 2025 15:01:13 -0500 Subject: [PATCH 25/42] Addressed comments --- proxy_worker/__init__.py | 2 ++ proxy_worker/dispatcher.py | 43 ++++++++++++++--------- proxy_worker/start_worker.py | 3 +- proxy_worker/utils/app_settings.py | 48 ------------------------- proxy_worker/utils/common.py | 52 ++++++++++++++++++++++++++++ proxy_worker/utils/constants.py | 4 +++ proxy_worker/version.py | 5 ++- python/prodV4/worker.py | 3 ++ python/proxyV4/worker.py | 3 ++ setup.cfg | 2 +- tests/unittests/test_code_quality.py | 2 +- 11 files changed, 97 insertions(+), 70 deletions(-) delete mode 100644 proxy_worker/utils/app_settings.py diff --git a/proxy_worker/__init__.py b/proxy_worker/__init__.py index e69de29bb..5b7f7a925 100644 --- a/proxy_worker/__init__.py +++ b/proxy_worker/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. diff --git a/proxy_worker/dispatcher.py b/proxy_worker/dispatcher.py index e0d7e23a9..c4e81d592 100644 --- a/proxy_worker/dispatcher.py +++ b/proxy_worker/dispatcher.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + import asyncio import concurrent.futures import logging @@ -22,8 +25,8 @@ is_system_log_category, logger, ) -from proxy_worker.utils.app_settings import get_app_setting -from proxy_worker.utils.common import is_envvar_true +from proxy_worker.utils.common import get_app_setting +from proxy_worker.utils.common import is_envvar_true, get_script_file_name from proxy_worker.utils.constants import PYTHON_ENABLE_DEBUG_LOGGING, \ PYTHON_THREADPOOL_THREAD_COUNT from proxy_worker.version import VERSION @@ -127,8 +130,6 @@ def __init__(self, loop: AbstractEventLoop, host: str, port: int, self._grpc_thread: threading.Thread = threading.Thread( name='grpc_local-thread', target=self.__poll_grpc) - # TODO: Need to find a better place for these - self._function_data_cache_enabled = False self._sync_call_tp: concurrent.futures.Executor = ( self._create_sync_call_tp(self._get_sync_tp_max_workers())) @@ -381,21 +382,25 @@ async def _handle__worker_init_request(self, request): global _library_worker directory = request.worker_init_request.function_app_directory - v2_directory = os.path.join(directory, 'function_app.py') + v2_directory = os.path.join(directory, get_script_file_name()) if os.path.exists(v2_directory): try: import azure_functions_worker_v2 # NoQA _library_worker = azure_functions_worker_v2 - logger.debug("V2 worker import succeeded: %s", _library_worker.__file__) + logger.debug("azure_functions_worker_v2 import succeeded: %s", + _library_worker.__file__) except ImportError: - logger.warning("Error importing V2 library: %s", traceback.format_exc()) + logger.debug("azure_functions_worker_v2 library not found: : %s", + traceback.format_exc()) else: try: import azure_functions_worker_v1 # NoQA _library_worker = azure_functions_worker_v1 - logger.debug("V1 worker import succeeded: %s", _library_worker.__file__) + logger.debug("azure_functions_worker_v1 import succeeded: %s", + _library_worker.__file__) except ImportError: - logger.warning("Error importing V1 library: %s", traceback.format_exc()) + logger.debug("azure_functions_worker_v1 library not found: %s", + traceback.format_exc()) init_request = WorkerRequest(name="WorkerInitRequest", request=request, @@ -420,22 +425,25 @@ async def _handle__function_environment_reload_request(self, request): DependencyManager.prioritize_customer_dependencies(directory) global _library_worker - directory = func_env_reload_request.function_app_directory - v2_directory = os.path.join(directory, 'function_app.py') + v2_directory = os.path.join(directory, get_script_file_name()) if os.path.exists(v2_directory): try: import azure_functions_worker_v2 # NoQA _library_worker = azure_functions_worker_v2 - logger.debug("V2 worker import succeeded: %s", _library_worker.__file__) + logger.debug("azure_functions_worker_v2 import succeeded: %s", + _library_worker.__file__) except ImportError: - logger.warning("Error importing V2 library: %s", traceback.format_exc()) + logger.warning("azure_functions_worker_v2 library not found: %s", + traceback.format_exc()) else: try: import azure_functions_worker_v1 # NoQA _library_worker = azure_functions_worker_v1 - logger.debug("V1 worker import succeeded: %s", _library_worker.__file__) + logger.debug("azure_functions_worker_v1 import succeeded: %s", + _library_worker.__file__) except ImportError: - logger.warning("Error importing V1 library: %s", traceback.format_exc()) + logger.warning("azure_functions_worker_v1 library not found: %s", + traceback.format_exc()) env_reload_request = WorkerRequest(name="FunctionEnvironmentReloadRequest", request=request, @@ -481,7 +489,7 @@ async def _handle__function_load_request(self, request): 'function_name: %s, worker_id: %s', self.request_id, function_id, function_name, self.worker_id) - load_request = WorkerRequest(name="FunctionsLoadRequest", request=request) + load_request = WorkerRequest(name="FunctionLoadRequest ", request=request) load_response = await _library_worker.function_load_request(load_request) return protos.StreamingMessage( @@ -498,7 +506,8 @@ async def _handle__invocation_request(self, request): 'invocation_id: %s, worker_id: %s', self.request_id, function_id, invocation_id, self.worker_id) - invocation_request = WorkerRequest(name="WorkerInvRequest", request=request, + invocation_request = WorkerRequest(name="FunctionInvocationRequest", + request=request, properties={ "threadpool": self._sync_call_tp}) invocation_response = await _library_worker.invocation_request( diff --git a/proxy_worker/start_worker.py b/proxy_worker/start_worker.py index c60d60561..f5dc9a6a0 100644 --- a/proxy_worker/start_worker.py +++ b/proxy_worker/start_worker.py @@ -55,7 +55,7 @@ def start(): logging.setup(log_level=args.log_level, log_destination=args.log_to) logger.info("Args: %s", args) - logger.info('Starting Azure Functions Python Worker.') + logger.info('Starting proxy worker.') logger.info('Worker ID: %s, Request ID: %s, Host Address: %s:%s', args.worker_id, args.request_id, args.host, args.port) @@ -72,7 +72,6 @@ def start(): async def start_async(host, port, worker_id, request_id): from . import dispatcher - # ToDo: Fix functions_grpc_max_msg_len. Needs to be parsed from args disp = await dispatcher.Dispatcher.connect(host=host, port=port, worker_id=worker_id, request_id=request_id, diff --git a/proxy_worker/utils/app_settings.py b/proxy_worker/utils/app_settings.py deleted file mode 100644 index 73e9fcc85..000000000 --- a/proxy_worker/utils/app_settings.py +++ /dev/null @@ -1,48 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import os -from typing import Callable, Optional - - -def get_app_setting( - setting: str, - default_value: Optional[str] = None, - validator: Optional[Callable[[str], bool]] = None -) -> Optional[str]: - """Returns the application setting from environment variable. - - Parameters - ---------- - setting: str - The name of the application setting (e.g. FUNCTIONS_RUNTIME_VERSION) - - default_value: Optional[str] - The expected return value when the application setting is not found, - or the app setting does not pass the validator. - - validator: Optional[Callable[[str], bool]] - A function accepts the app setting value and should return True when - the app setting value is acceptable. - - Returns - ------- - Optional[str] - A string value that is set in the application setting - """ - app_setting_value = os.getenv(setting) - - # If an app setting is not configured, we return the default value - if app_setting_value is None: - return default_value - - # If there's no validator, we should return the app setting value directly - if validator is None: - return app_setting_value - - # If the app setting is set with a validator, - # On True, should return the app setting value - # On False, should return the default value - if validator(app_setting_value): - return app_setting_value - return default_value diff --git a/proxy_worker/utils/common.py b/proxy_worker/utils/common.py index ea5f0e022..5bcb9cd0c 100644 --- a/proxy_worker/utils/common.py +++ b/proxy_worker/utils/common.py @@ -2,6 +2,53 @@ # Licensed under the MIT License. import os +from typing import Optional, Callable + +from proxy_worker.utils.constants import PYTHON_SCRIPT_FILE_NAME_DEFAULT, \ + PYTHON_SCRIPT_FILE_NAME + + +def get_app_setting( + setting: str, + default_value: Optional[str] = None, + validator: Optional[Callable[[str], bool]] = None +) -> Optional[str]: + """Returns the application setting from environment variable. + + Parameters + ---------- + setting: str + The name of the application setting (e.g. FUNCTIONS_RUNTIME_VERSION) + + default_value: Optional[str] + The expected return value when the application setting is not found, + or the app setting does not pass the validator. + + validator: Optional[Callable[[str], bool]] + A function accepts the app setting value and should return True when + the app setting value is acceptable. + + Returns + ------- + Optional[str] + A string value that is set in the application setting + """ + app_setting_value = os.getenv(setting) + + # If an app setting is not configured, we return the default value + if app_setting_value is None: + return default_value + + # If there's no validator, we should return the app setting value directly + if validator is None: + return app_setting_value + + # If the app setting is set with a validator, + # On True, should return the app setting value + # On False, should return the default value + if validator(app_setting_value): + return app_setting_value + return default_value def is_true_like(setting: str) -> bool: @@ -30,3 +77,8 @@ def is_envvar_false(env_key: str) -> bool: return False return is_false_like(os.environ[env_key]) + + +def get_script_file_name(): + return get_app_setting(PYTHON_SCRIPT_FILE_NAME, + PYTHON_SCRIPT_FILE_NAME_DEFAULT) diff --git a/proxy_worker/utils/constants.py b/proxy_worker/utils/constants.py index d94075cf7..abcf84354 100644 --- a/proxy_worker/utils/constants.py +++ b/proxy_worker/utils/constants.py @@ -7,3 +7,7 @@ CONTAINER_NAME = "CONTAINER_NAME" AZURE_WEBJOBS_SCRIPT_ROOT = "AzureWebJobsScriptRoot" + +# new programming model default script file name +PYTHON_SCRIPT_FILE_NAME = "PYTHON_SCRIPT_FILE_NAME" +PYTHON_SCRIPT_FILE_NAME_DEFAULT = "function_app.py" diff --git a/proxy_worker/version.py b/proxy_worker/version.py index 3277f64c2..b5a827733 100644 --- a/proxy_worker/version.py +++ b/proxy_worker/version.py @@ -1 +1,4 @@ -VERSION = "1.0.0" +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +VERSION = "4.36.1" diff --git a/python/prodV4/worker.py b/python/prodV4/worker.py index 021fa3f03..512f7f687 100644 --- a/python/prodV4/worker.py +++ b/python/prodV4/worker.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + import os import pathlib import sys diff --git a/python/proxyV4/worker.py b/python/proxyV4/worker.py index 52326792c..57889b151 100644 --- a/python/proxyV4/worker.py +++ b/python/proxyV4/worker.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + import os import pathlib import sys diff --git a/setup.cfg b/setup.cfg index 5dde99ef5..2fe4fd01b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,7 +3,7 @@ addopts = --capture=no --assert=plain --strict --tb native testpaths = tests [mypy] -python_version = 3.6 +python_version = 3.13 check_untyped_defs = True warn_redundant_casts = True warn_unused_ignores = True diff --git a/tests/unittests/test_code_quality.py b/tests/unittests/test_code_quality.py index c50088e9d..0ca2940f2 100644 --- a/tests/unittests/test_code_quality.py +++ b/tests/unittests/test_code_quality.py @@ -17,7 +17,7 @@ def test_mypy(self): try: subprocess.run( - [sys.executable, '-m', 'mypy', '-m', 'azure_functions_worker', + [sys.executable, '-m', 'mypy', 'azure_functions_worker', 'proxy_worker'], check=True, stdout=subprocess.PIPE, From f439425ec7b1b43be942d546647592faeb7e4e0c Mon Sep 17 00:00:00 2001 From: Gavin Aguiar Date: Fri, 11 Apr 2025 16:06:50 -0500 Subject: [PATCH 26/42] Updated unit test and added missing protos files --- proxy_worker/protos/__init__.py | 43 +++++++++++++++++++ proxy_worker/protos/identity/__init__.py | 0 proxy_worker/protos/shared/__init__.py | 0 .../test_dependency.py | 0 .../test_dispatcher.py | 13 +++--- 5 files changed, 51 insertions(+), 5 deletions(-) create mode 100644 proxy_worker/protos/__init__.py create mode 100644 proxy_worker/protos/identity/__init__.py create mode 100644 proxy_worker/protos/shared/__init__.py rename tests/{unittests/proxy_worker => unittest_proxy}/test_dependency.py (100%) rename tests/{unittests/proxy_worker => unittest_proxy}/test_dispatcher.py (95%) diff --git a/proxy_worker/protos/__init__.py b/proxy_worker/protos/__init__.py new file mode 100644 index 000000000..e9c4f2397 --- /dev/null +++ b/proxy_worker/protos/__init__.py @@ -0,0 +1,43 @@ +from .FunctionRpc_pb2_grpc import ( # NoQA + FunctionRpcStub, + FunctionRpcServicer, + add_FunctionRpcServicer_to_server) + +from .FunctionRpc_pb2 import ( # NoQA + StreamingMessage, + StartStream, + WorkerInitRequest, + WorkerInitResponse, + RpcFunctionMetadata, + FunctionLoadRequest, + FunctionLoadResponse, + FunctionEnvironmentReloadRequest, + FunctionEnvironmentReloadResponse, + InvocationRequest, + InvocationResponse, + WorkerHeartbeat, + WorkerStatusRequest, + WorkerStatusResponse, + BindingInfo, + StatusResult, + RpcException, + ParameterBinding, + TypedData, + RpcHttp, + RpcHttpCookie, + RpcLog, + RpcSharedMemory, + RpcDataType, + CloseSharedMemoryResourcesRequest, + CloseSharedMemoryResourcesResponse, + FunctionsMetadataRequest, + FunctionMetadataResponse, + WorkerMetadata, + RpcRetryOptions) + +from .shared.NullableTypes_pb2 import ( + NullableString, + NullableBool, + NullableDouble, + NullableTimestamp +) diff --git a/proxy_worker/protos/identity/__init__.py b/proxy_worker/protos/identity/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/proxy_worker/protos/shared/__init__.py b/proxy_worker/protos/shared/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unittests/proxy_worker/test_dependency.py b/tests/unittest_proxy/test_dependency.py similarity index 100% rename from tests/unittests/proxy_worker/test_dependency.py rename to tests/unittest_proxy/test_dependency.py diff --git a/tests/unittests/proxy_worker/test_dispatcher.py b/tests/unittest_proxy/test_dispatcher.py similarity index 95% rename from tests/unittests/proxy_worker/test_dispatcher.py rename to tests/unittest_proxy/test_dispatcher.py index f0780b2f8..22c38fa0a 100644 --- a/tests/unittests/proxy_worker/test_dispatcher.py +++ b/tests/unittest_proxy/test_dispatcher.py @@ -106,7 +106,8 @@ async def test_worker_init_v2_import( result = await dispatcher._handle__worker_init_request(request) assert result == "mocked_streaming_response" - mock_logger.debug.assert_any_call("V2 worker import succeeded: %s", ANY) + mock_logger.debug.assert_any_call("azure_functions_worker_v2 import succeeded: %s", + ANY) @patch("proxy_worker.dispatcher.DependencyManager.should_load_cx_dependencies", @@ -130,7 +131,8 @@ async def test_worker_init_fallback_to_v1( result = await dispatcher._handle__worker_init_request(request) assert result == "mocked_streaming_response" - mock_logger.debug.assert_any_call("V1 worker import succeeded: %s", ANY) + mock_logger.debug.assert_any_call("azure_functions_worker_v1 import succeeded: %s", + ANY) @patch("proxy_worker.dispatcher.DependencyManager.prioritize_customer_dependencies") @@ -153,7 +155,8 @@ async def test_function_environment_reload_v2_import( result = await dispatcher._handle__function_environment_reload_request(request) assert result == "mocked_reload_response" - mock_logger.debug.assert_any_call("V2 worker import succeeded: %s", ANY) + mock_logger.debug.assert_any_call("azure_functions_worker_v2 import succeeded: %s", + ANY) @patch("proxy_worker.dispatcher.DependencyManager.prioritize_customer_dependencies") @@ -174,8 +177,8 @@ async def test_function_environment_reload_fallback_to_v1( result = await dispatcher._handle__function_environment_reload_request(request) assert result == "mocked_reload_response" - mock_logger.debug.assert_any_call("V1 worker import succeeded: %s", - "azure_functions_worker_v1.py") + mock_logger.debug.assert_any_call("azure_functions_worker_v1 import succeeded: %s", + ANY) @patch("proxy_worker.dispatcher._library_worker", From fb1823ce65ae389841dd32372fd4b0dbc1d0bf9f Mon Sep 17 00:00:00 2001 From: hallvictoria Date: Mon, 14 Apr 2025 16:11:32 -0500 Subject: [PATCH 27/42] fix e2e test reference --- tests/utils/testutils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/utils/testutils.py b/tests/utils/testutils.py index 9e16460f2..f90bd3258 100644 --- a/tests/utils/testutils.py +++ b/tests/utils/testutils.py @@ -65,8 +65,7 @@ from azure_functions_worker.utils.common import get_app_setting, is_envvar_true else: from proxy_worker import dispatcher, protos - from proxy_worker.utils.common import is_envvar_true - from proxy_worker.utils.app_settings import get_app_setting + from proxy_worker.utils.common import get_app_setting, is_envvar_true TESTS_ROOT = PROJECT_ROOT / 'tests' E2E_TESTS_FOLDER = pathlib.Path('endtoend') From 4ac586fa104db481da8d1f216d7b41ae433f057b Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Tue, 15 Apr 2025 11:54:20 -0500 Subject: [PATCH 28/42] lint, mypy, add 3.13 to unittests --- eng/templates/jobs/ci-unit-tests.yml | 16 ++++++- eng/templates/official/jobs/ci-e2e-tests.yml | 4 +- proxy_worker/dispatcher.py | 44 ++++++++++++------- proxy_worker/utils/dependency.py | 2 +- .../test_deferred_bindings.py | 17 ++++++- tests/unittests/test_code_quality.py | 5 ++- 6 files changed, 63 insertions(+), 25 deletions(-) diff --git a/eng/templates/jobs/ci-unit-tests.yml b/eng/templates/jobs/ci-unit-tests.yml index 5ff54888c..55e388b68 100644 --- a/eng/templates/jobs/ci-unit-tests.yml +++ b/eng/templates/jobs/ci-unit-tests.yml @@ -16,7 +16,8 @@ jobs: PYTHON_VERSION: '3.11' Python312: PYTHON_VERSION: '3.12' - + Python313: + PYTHON_VERSION: '3.13' steps: - task: UsePythonVersion@0 inputs: @@ -34,8 +35,19 @@ jobs: displayName: 'Install dependencies' condition: and(eq(variables.isSdkRelease, false), eq(variables.isExtensionsRelease, false), eq(variables['USETESTPYTHONSDK'], false), eq(variables['USETESTPYTHONEXTENSIONS'], false)) - bash: | - python -m pytest -q -n auto --dist loadfile --reruns 4 --instafail --cov=./azure_functions_worker --cov-report xml --cov-branch tests/unittests + echo "Python version from variable: $PYTHON_VERSION" + if (( $(echo "$PYTHON_VERSION < 3.13" | bc -l) )); then + echo "Running unittests (Python < 3.13)..." + python -m pytest -q -n auto --dist loadfile --reruns 4 --instafail \ + --cov=./azure_functions_worker --cov-report xml --cov-branch tests/unittests + else + echo "Running proxy_worker tests (Python >= 3.13)..." + python -m pytest -q -n auto --dist loadfile --reruns 4 --instafail \ + --cov=./azure_functions_worker --cov-report xml --cov-branch tests/uni + fi displayName: "Running $(PYTHON_VERSION) Unit Tests" # Skip running tests for SDK and Extensions release branches. Public pipeline doesn't have permissions to download artifact. condition: and(eq(variables.isSdkRelease, false), eq(variables.isExtensionsRelease, false), eq(variables['USETESTPYTHONSDK'], false), eq(variables['USETESTPYTHONEXTENSIONS'], false)) + env: + PYTHON_VERSION: $(PYTHON_VERSION) # Add as part of env for easier parsing \ No newline at end of file diff --git a/eng/templates/official/jobs/ci-e2e-tests.yml b/eng/templates/official/jobs/ci-e2e-tests.yml index 33148bf94..cd6c2d705 100644 --- a/eng/templates/official/jobs/ci-e2e-tests.yml +++ b/eng/templates/official/jobs/ci-e2e-tests.yml @@ -129,7 +129,7 @@ jobs: Write-Host "pipelineVarSet: $pipelineVarSet" $branch = "$(Build.SourceBranch)" Write-Host "Branch: $branch" - if($branch.StartsWith("refs/heads/sdk/") -or $pipelineVarSet -eq "true") + if($branch.StartsWith("refs/heads/sdk/") -or $pipelineVarSet -eq "true" -or $(echo "$PYTHON_VERSION >= 3.13" | bc -l) )) { Write-Host "##vso[task.setvariable variable=skipTest;]true" } @@ -139,6 +139,8 @@ jobs: } displayName: 'Set skipTest variable' condition: or(eq(variables.isSdkRelease, true), eq(variables['USETESTPYTHONSDK'], true)) + env: + PYTHON_VERSION: $(PYTHON_VERSION) # Add as part of env for easier parsing - powershell: | Write-Host "skipTest: $(skipTest)" displayName: 'Display skipTest variable' diff --git a/proxy_worker/dispatcher.py b/proxy_worker/dispatcher.py index c4e81d592..839ef9c7a 100644 --- a/proxy_worker/dispatcher.py +++ b/proxy_worker/dispatcher.py @@ -12,7 +12,7 @@ import typing from asyncio import AbstractEventLoop from dataclasses import dataclass -from typing import Optional +from typing import Any, Optional import grpc @@ -56,7 +56,7 @@ def set_azure_invocation_id(self, invocation_id: str) -> None: _invocation_id_local = threading.local() -def get_current_invocation_id() -> Optional[str]: +def get_current_invocation_id() -> Optional[Any]: loop = asyncio._get_running_loop() if loop is not None: current_task = asyncio.current_task(loop) @@ -99,7 +99,7 @@ class WorkerRequest: class DispatcherMeta(type): - __current_dispatcher__ = None + __current_dispatcher__: Optional["Dispatcher"] = None @property def current(cls): @@ -123,14 +123,14 @@ def __init__(self, loop: AbstractEventLoop, host: str, port: int, self._worker_id = worker_id self._grpc_connect_timeout: float = grpc_connect_timeout self._grpc_max_msg_len: int = grpc_max_msg_len - self._old_task_factory = None + self._old_task_factory: Optional[Any] = None self._grpc_resp_queue: queue.Queue = queue.Queue() self._grpc_connected_fut = loop.create_future() - self._grpc_thread: threading.Thread = threading.Thread( + self._grpc_thread: Optional[threading.Thread] = threading.Thread( name='grpc_local-thread', target=self.__poll_grpc) - self._sync_call_tp: concurrent.futures.Executor = ( + self._sync_call_tp: Optional[concurrent.futures.Executor] = ( self._create_sync_call_tp(self._get_sync_tp_max_workers())) def on_logging(self, record: logging.LogRecord, @@ -182,7 +182,9 @@ async def connect(cls, host: str, port: int, worker_id: str, request_id: str, connect_timeout: float): loop = asyncio.events.get_event_loop() disp = cls(loop, host, port, worker_id, request_id, connect_timeout) - disp._grpc_thread.start() + # Safety check for mypy + if disp._grpc_thread is not None: + disp._grpc_thread.start() await disp._grpc_connected_fut logger.info('Successfully opened gRPC channel to %s:%s ', host, port) return disp @@ -327,6 +329,7 @@ def _stop_sync_call_tp(self): this will be a no op. """ if getattr(self, '_sync_call_tp', None): + assert self._sync_call_tp is not None # mypy fix self._sync_call_tp.shutdown() self._sync_call_tp = None @@ -397,7 +400,7 @@ async def _handle__worker_init_request(self, request): import azure_functions_worker_v1 # NoQA _library_worker = azure_functions_worker_v1 logger.debug("azure_functions_worker_v1 import succeeded: %s", - _library_worker.__file__) + _library_worker.__file__) # type: ignore[union-attr] except ImportError: logger.debug("azure_functions_worker_v1 library not found: %s", traceback.format_exc()) @@ -406,7 +409,9 @@ async def _handle__worker_init_request(self, request): request=request, properties={"protos": protos, "host": self._host}) - init_response = await _library_worker.worker_init_request(init_request) + init_response = await ( + _library_worker.worker_init_request( # type: ignore[union-attr] + init_request)) return protos.StreamingMessage( request_id=self.request_id, @@ -440,7 +445,7 @@ async def _handle__function_environment_reload_request(self, request): import azure_functions_worker_v1 # NoQA _library_worker = azure_functions_worker_v1 logger.debug("azure_functions_worker_v1 import succeeded: %s", - _library_worker.__file__) + _library_worker.__file__) # type: ignore[union-attr] except ImportError: logger.warning("azure_functions_worker_v1 library not found: %s", traceback.format_exc()) @@ -449,8 +454,9 @@ async def _handle__function_environment_reload_request(self, request): request=request, properties={"protos": protos, "host": self._host}) - env_reload_response = await _library_worker.function_environment_reload_request( - env_reload_request) + env_reload_response = await ( + _library_worker.function_environment_reload_request( # type: ignore[union-attr] # noqa + env_reload_request)) return protos.StreamingMessage( request_id=self.request_id, @@ -471,8 +477,9 @@ async def _handle__functions_metadata_request(self, request): self.request_id, self.worker_id) metadata_request = WorkerRequest(name="WorkerMetadataRequest", request=request) - metadata_response = await _library_worker.functions_metadata_request( - metadata_request) + metadata_response = await ( + _library_worker.functions_metadata_request( # type: ignore[union-attr] + metadata_request)) return protos.StreamingMessage( request_id=request.request_id, @@ -490,7 +497,9 @@ async def _handle__function_load_request(self, request): self.request_id, function_id, function_name, self.worker_id) load_request = WorkerRequest(name="FunctionLoadRequest ", request=request) - load_response = await _library_worker.function_load_request(load_request) + load_response = await ( + _library_worker.function_load_request( # type: ignore[union-attr] + load_request)) return protos.StreamingMessage( request_id=self.request_id, @@ -510,8 +519,9 @@ async def _handle__invocation_request(self, request): request=request, properties={ "threadpool": self._sync_call_tp}) - invocation_response = await _library_worker.invocation_request( - invocation_request) + invocation_response = await ( + _library_worker.invocation_request( # type: ignore[union-attr] + invocation_request)) return protos.StreamingMessage( request_id=self.request_id, diff --git a/proxy_worker/utils/dependency.py b/proxy_worker/utils/dependency.py index 0df0e4cd2..3cde31430 100644 --- a/proxy_worker/utils/dependency.py +++ b/proxy_worker/utils/dependency.py @@ -122,7 +122,7 @@ def prioritize_customer_dependencies(cls, cx_working_dir=None): # cx_working_dir => cls.cx_working_dir => AzureWebJobsScriptRoot working_directory: str = '' if cx_working_dir: - working_directory: str = os.path.abspath(cx_working_dir) + working_directory = os.path.abspath(cx_working_dir) if not working_directory: working_directory = cls.cx_working_dir if not working_directory: diff --git a/tests/extension_tests/deferred_bindings_tests/test_deferred_bindings.py b/tests/extension_tests/deferred_bindings_tests/test_deferred_bindings.py index c527cb680..d6ff94ed7 100644 --- a/tests/extension_tests/deferred_bindings_tests/test_deferred_bindings.py +++ b/tests/extension_tests/deferred_bindings_tests/test_deferred_bindings.py @@ -6,8 +6,9 @@ import azure.functions as func from tests.utils import testutils -from azure_functions_worker import protos -from azure_functions_worker.bindings import datumdef, meta +if sys.version_info.minor < 13: + from azure_functions_worker import protos + from azure_functions_worker.bindings import datumdef, meta # Even if the tests are skipped for <=3.8, the library is still imported as # it is used for these tests. @@ -42,6 +43,9 @@ def __init__(self, version: str, source: str, @unittest.skipIf(sys.version_info.minor <= 8, "The base extension" "is only supported for 3.9+.") +@unittest.skipIf(sys.version_info.minor < 13, "For python 3.13+," + "this logic is in the" + "library worker.") class TestDeferredBindingsEnabled(testutils.AsyncTestCase): @testutils.retryable_test(3, 5) @@ -73,6 +77,9 @@ async def test_deferred_bindings_enabled_log(self): @unittest.skipIf(sys.version_info.minor <= 8, "The base extension" "is only supported for 3.9+.") +@unittest.skipIf(sys.version_info.minor < 13, "For python 3.13+," + "this logic is in the" + "library worker.") class TestDeferredBindingsDisabled(testutils.AsyncTestCase): @testutils.retryable_test(3, 5) @@ -104,6 +111,9 @@ async def test_deferred_bindings_disabled_log(self): @unittest.skipIf(sys.version_info.minor <= 8, "The base extension" "is only supported for 3.9+.") +@unittest.skipIf(sys.version_info.minor < 13, "For python 3.13+," + "this logic is in the" + "library worker.") class TestDeferredBindingsEnabledDual(testutils.AsyncTestCase): @testutils.retryable_test(3, 5) @@ -135,6 +145,9 @@ async def test_deferred_bindings_dual_enabled_log(self): @unittest.skipIf(sys.version_info.minor <= 8, "The base extension" "is only supported for 3.9+.") +@unittest.skipIf(sys.version_info.minor < 13, "For python 3.13+," + "this logic is in the" + "library worker.") class TestDeferredBindingsHelpers(testutils.AsyncTestCase): def test_mbd_deferred_bindings_enabled_decode(self): diff --git a/tests/unittests/test_code_quality.py b/tests/unittests/test_code_quality.py index 0ca2940f2..6a28efe22 100644 --- a/tests/unittests/test_code_quality.py +++ b/tests/unittests/test_code_quality.py @@ -6,6 +6,8 @@ import unittest ROOT_PATH = pathlib.Path(__file__).parent.parent.parent +WORKER_DIRECTORY = "azure_functions_worker"\ + if sys.version_info.minor < 13 else "proxy_worker" class TestCodeQuality(unittest.TestCase): @@ -17,8 +19,7 @@ def test_mypy(self): try: subprocess.run( - [sys.executable, '-m', 'mypy', 'azure_functions_worker', - 'proxy_worker'], + [sys.executable, '-m', 'mypy', WORKER_DIRECTORY], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, From f6b6551ea58462883aee542ae97adbd2c61af349 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Tue, 15 Apr 2025 13:22:14 -0500 Subject: [PATCH 29/42] correct version check --- eng/templates/jobs/ci-unit-tests.yml | 19 ++++++++------- eng/templates/official/jobs/ci-e2e-tests.yml | 11 ++++++--- .../test_deferred_bindings.py | 24 +++++++++---------- 3 files changed, 31 insertions(+), 23 deletions(-) diff --git a/eng/templates/jobs/ci-unit-tests.yml b/eng/templates/jobs/ci-unit-tests.yml index 55e388b68..b2fafe50b 100644 --- a/eng/templates/jobs/ci-unit-tests.yml +++ b/eng/templates/jobs/ci-unit-tests.yml @@ -35,19 +35,22 @@ jobs: displayName: 'Install dependencies' condition: and(eq(variables.isSdkRelease, false), eq(variables.isExtensionsRelease, false), eq(variables['USETESTPYTHONSDK'], false), eq(variables['USETESTPYTHONEXTENSIONS'], false)) - bash: | - echo "Python version from variable: $PYTHON_VERSION" - if (( $(echo "$PYTHON_VERSION < 3.13" | bc -l) )); then - echo "Running unittests (Python < 3.13)..." + PY_VER="$(PYTHON_VERSION)" + echo "Python version: $PY_VER" + + # Extract minor version as integers + PY_MINOR="${PY_VER#*.}" + + if [ "$PY_MINOR" -ge 13 ] then + echo "Running proxy_worker tests (Python >= 3.13)..." python -m pytest -q -n auto --dist loadfile --reruns 4 --instafail \ - --cov=./azure_functions_worker --cov-report xml --cov-branch tests/unittests + --cov=./azure_functions_worker --cov-report xml --cov-branch tests/unittests_proxy else - echo "Running proxy_worker tests (Python >= 3.13)..." + echo "Running unittests (Python < 3.13)..." python -m pytest -q -n auto --dist loadfile --reruns 4 --instafail \ - --cov=./azure_functions_worker --cov-report xml --cov-branch tests/uni + --cov=./azure_functions_worker --cov-report xml --cov-branch tests/unittests fi displayName: "Running $(PYTHON_VERSION) Unit Tests" # Skip running tests for SDK and Extensions release branches. Public pipeline doesn't have permissions to download artifact. condition: and(eq(variables.isSdkRelease, false), eq(variables.isExtensionsRelease, false), eq(variables['USETESTPYTHONSDK'], false), eq(variables['USETESTPYTHONEXTENSIONS'], false)) - env: - PYTHON_VERSION: $(PYTHON_VERSION) # Add as part of env for easier parsing \ No newline at end of file diff --git a/eng/templates/official/jobs/ci-e2e-tests.yml b/eng/templates/official/jobs/ci-e2e-tests.yml index cd6c2d705..9222d53eb 100644 --- a/eng/templates/official/jobs/ci-e2e-tests.yml +++ b/eng/templates/official/jobs/ci-e2e-tests.yml @@ -129,7 +129,14 @@ jobs: Write-Host "pipelineVarSet: $pipelineVarSet" $branch = "$(Build.SourceBranch)" Write-Host "Branch: $branch" - if($branch.StartsWith("refs/heads/sdk/") -or $pipelineVarSet -eq "true" -or $(echo "$PYTHON_VERSION >= 3.13" | bc -l) )) + + PY_VER="$(PYTHON_VERSION)" + Write-Host "Python version: $PY_VER" + # Extract minor version as integers + PY_MINOR="${PY_VER#*.}" + Write-Host "Branch: PY_MINOR" + + if($branch.StartsWith("refs/heads/sdk/") -or $pipelineVarSet -eq "true" -or $PY_MINOR -ge 13 ) { Write-Host "##vso[task.setvariable variable=skipTest;]true" } @@ -139,8 +146,6 @@ jobs: } displayName: 'Set skipTest variable' condition: or(eq(variables.isSdkRelease, true), eq(variables['USETESTPYTHONSDK'], true)) - env: - PYTHON_VERSION: $(PYTHON_VERSION) # Add as part of env for easier parsing - powershell: | Write-Host "skipTest: $(skipTest)" displayName: 'Display skipTest variable' diff --git a/tests/extension_tests/deferred_bindings_tests/test_deferred_bindings.py b/tests/extension_tests/deferred_bindings_tests/test_deferred_bindings.py index d6ff94ed7..b8ced2834 100644 --- a/tests/extension_tests/deferred_bindings_tests/test_deferred_bindings.py +++ b/tests/extension_tests/deferred_bindings_tests/test_deferred_bindings.py @@ -43,9 +43,9 @@ def __init__(self, version: str, source: str, @unittest.skipIf(sys.version_info.minor <= 8, "The base extension" "is only supported for 3.9+.") -@unittest.skipIf(sys.version_info.minor < 13, "For python 3.13+," - "this logic is in the" - "library worker.") +@unittest.skipIf(sys.version_info.minor >= 13, "For python 3.13+," + "this logic is in the" + "library worker.") class TestDeferredBindingsEnabled(testutils.AsyncTestCase): @testutils.retryable_test(3, 5) @@ -77,9 +77,9 @@ async def test_deferred_bindings_enabled_log(self): @unittest.skipIf(sys.version_info.minor <= 8, "The base extension" "is only supported for 3.9+.") -@unittest.skipIf(sys.version_info.minor < 13, "For python 3.13+," - "this logic is in the" - "library worker.") +@unittest.skipIf(sys.version_info.minor >= 13, "For python 3.13+," + "this logic is in the" + "library worker.") class TestDeferredBindingsDisabled(testutils.AsyncTestCase): @testutils.retryable_test(3, 5) @@ -111,9 +111,9 @@ async def test_deferred_bindings_disabled_log(self): @unittest.skipIf(sys.version_info.minor <= 8, "The base extension" "is only supported for 3.9+.") -@unittest.skipIf(sys.version_info.minor < 13, "For python 3.13+," - "this logic is in the" - "library worker.") +@unittest.skipIf(sys.version_info.minor >= 13, "For python 3.13+," + "this logic is in the" + "library worker.") class TestDeferredBindingsEnabledDual(testutils.AsyncTestCase): @testutils.retryable_test(3, 5) @@ -145,9 +145,9 @@ async def test_deferred_bindings_dual_enabled_log(self): @unittest.skipIf(sys.version_info.minor <= 8, "The base extension" "is only supported for 3.9+.") -@unittest.skipIf(sys.version_info.minor < 13, "For python 3.13+," - "this logic is in the" - "library worker.") +@unittest.skipIf(sys.version_info.minor >= 13, "For python 3.13+," + "this logic is in the" + "library worker.") class TestDeferredBindingsHelpers(testutils.AsyncTestCase): def test_mbd_deferred_bindings_enabled_decode(self): From 1e7e7322ecb8c5f50f74c9c7be330adc188daf48 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Tue, 15 Apr 2025 13:46:28 -0500 Subject: [PATCH 30/42] syntax --- eng/templates/jobs/ci-unit-tests.yml | 2 +- eng/templates/official/jobs/ci-e2e-tests.yml | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/eng/templates/jobs/ci-unit-tests.yml b/eng/templates/jobs/ci-unit-tests.yml index b2fafe50b..6c0626df1 100644 --- a/eng/templates/jobs/ci-unit-tests.yml +++ b/eng/templates/jobs/ci-unit-tests.yml @@ -41,7 +41,7 @@ jobs: # Extract minor version as integers PY_MINOR="${PY_VER#*.}" - if [ "$PY_MINOR" -ge 13 ] then + if [ "$PY_MINOR" -ge 13 ]; then echo "Running proxy_worker tests (Python >= 3.13)..." python -m pytest -q -n auto --dist loadfile --reruns 4 --instafail \ --cov=./azure_functions_worker --cov-report xml --cov-branch tests/unittests_proxy diff --git a/eng/templates/official/jobs/ci-e2e-tests.yml b/eng/templates/official/jobs/ci-e2e-tests.yml index 9222d53eb..ebef20830 100644 --- a/eng/templates/official/jobs/ci-e2e-tests.yml +++ b/eng/templates/official/jobs/ci-e2e-tests.yml @@ -145,7 +145,6 @@ jobs: Write-Host "##vso[task.setvariable variable=skipTest;]false" } displayName: 'Set skipTest variable' - condition: or(eq(variables.isSdkRelease, true), eq(variables['USETESTPYTHONSDK'], true)) - powershell: | Write-Host "skipTest: $(skipTest)" displayName: 'Display skipTest variable' From b36496242ea1509a9e06da3cb8cb418aa9919bbc Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Tue, 15 Apr 2025 15:50:32 -0500 Subject: [PATCH 31/42] syntax --- eng/templates/jobs/ci-unit-tests.yml | 4 ++-- eng/templates/official/jobs/ci-e2e-tests.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/eng/templates/jobs/ci-unit-tests.yml b/eng/templates/jobs/ci-unit-tests.yml index 6c0626df1..c888e2e0c 100644 --- a/eng/templates/jobs/ci-unit-tests.yml +++ b/eng/templates/jobs/ci-unit-tests.yml @@ -35,11 +35,11 @@ jobs: displayName: 'Install dependencies' condition: and(eq(variables.isSdkRelease, false), eq(variables.isExtensionsRelease, false), eq(variables['USETESTPYTHONSDK'], false), eq(variables['USETESTPYTHONEXTENSIONS'], false)) - bash: | - PY_VER="$(PYTHON_VERSION)" + $PY_VER = "$(PYTHON_VERSION)" echo "Python version: $PY_VER" # Extract minor version as integers - PY_MINOR="${PY_VER#*.}" + $PY_MINOR = "${PY_VER#*.}" if [ "$PY_MINOR" -ge 13 ]; then echo "Running proxy_worker tests (Python >= 3.13)..." diff --git a/eng/templates/official/jobs/ci-e2e-tests.yml b/eng/templates/official/jobs/ci-e2e-tests.yml index ebef20830..0b03b71a2 100644 --- a/eng/templates/official/jobs/ci-e2e-tests.yml +++ b/eng/templates/official/jobs/ci-e2e-tests.yml @@ -130,10 +130,10 @@ jobs: $branch = "$(Build.SourceBranch)" Write-Host "Branch: $branch" - PY_VER="$(PYTHON_VERSION)" + $PY_VER = "$(PYTHON_VERSION)" Write-Host "Python version: $PY_VER" # Extract minor version as integers - PY_MINOR="${PY_VER#*.}" + $PY_MINO = "${PY_VER#*.}" Write-Host "Branch: PY_MINOR" if($branch.StartsWith("refs/heads/sdk/") -or $pipelineVarSet -eq "true" -or $PY_MINOR -ge 13 ) From 3b1f96172869588c35dcaccaf0082d84a09594a1 Mon Sep 17 00:00:00 2001 From: hallvictoria Date: Fri, 18 Apr 2025 10:34:53 -0500 Subject: [PATCH 32/42] fix unit tests, mypy --- eng/templates/jobs/ci-unit-tests.yml | 18 ++++++++++-------- tests/unittests/test_code_quality.py | 4 +--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/eng/templates/jobs/ci-unit-tests.yml b/eng/templates/jobs/ci-unit-tests.yml index c888e2e0c..46e1e8149 100644 --- a/eng/templates/jobs/ci-unit-tests.yml +++ b/eng/templates/jobs/ci-unit-tests.yml @@ -35,22 +35,24 @@ jobs: displayName: 'Install dependencies' condition: and(eq(variables.isSdkRelease, false), eq(variables.isExtensionsRelease, false), eq(variables['USETESTPYTHONSDK'], false), eq(variables['USETESTPYTHONEXTENSIONS'], false)) - bash: | - $PY_VER = "$(PYTHON_VERSION)" + PY_VER="$(PYTHON_VERSION)" echo "Python version: $PY_VER" - - # Extract minor version as integers - $PY_MINOR = "${PY_VER#*.}" - - if [ "$PY_MINOR" -ge 13 ]; then + + # Extract minor version + PY_MINOR="${PY_VER#*.}" + + if [ "$PY_MINOR" -ge 13 ]; then echo "Running proxy_worker tests (Python >= 3.13)..." python -m pytest -q -n auto --dist loadfile --reruns 4 --instafail \ - --cov=./azure_functions_worker --cov-report xml --cov-branch tests/unittests_proxy + --cov=./azure_functions_worker --cov-report xml --cov-branch tests/unittests_proxy else echo "Running unittests (Python < 3.13)..." python -m pytest -q -n auto --dist loadfile --reruns 4 --instafail \ - --cov=./azure_functions_worker --cov-report xml --cov-branch tests/unittests + --cov=./azure_functions_worker --cov-report xml --cov-branch tests/unittests fi displayName: "Running $(PYTHON_VERSION) Unit Tests" # Skip running tests for SDK and Extensions release branches. Public pipeline doesn't have permissions to download artifact. condition: and(eq(variables.isSdkRelease, false), eq(variables.isExtensionsRelease, false), eq(variables['USETESTPYTHONSDK'], false), eq(variables['USETESTPYTHONEXTENSIONS'], false)) + env: + PYTHON_VERSION: $(PYTHON_VERSION) \ No newline at end of file diff --git a/tests/unittests/test_code_quality.py b/tests/unittests/test_code_quality.py index 6a28efe22..31a15a954 100644 --- a/tests/unittests/test_code_quality.py +++ b/tests/unittests/test_code_quality.py @@ -6,8 +6,6 @@ import unittest ROOT_PATH = pathlib.Path(__file__).parent.parent.parent -WORKER_DIRECTORY = "azure_functions_worker"\ - if sys.version_info.minor < 13 else "proxy_worker" class TestCodeQuality(unittest.TestCase): @@ -19,7 +17,7 @@ def test_mypy(self): try: subprocess.run( - [sys.executable, '-m', 'mypy', WORKER_DIRECTORY], + [sys.executable, '-m', 'azure_functions_worker'], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, From 4de671d484120a2ec52e56726deda72d77a652b4 Mon Sep 17 00:00:00 2001 From: hallvictoria Date: Fri, 18 Apr 2025 10:35:55 -0500 Subject: [PATCH 33/42] oops --- tests/unittests/test_code_quality.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unittests/test_code_quality.py b/tests/unittests/test_code_quality.py index 31a15a954..fa3ebffab 100644 --- a/tests/unittests/test_code_quality.py +++ b/tests/unittests/test_code_quality.py @@ -17,7 +17,7 @@ def test_mypy(self): try: subprocess.run( - [sys.executable, '-m', 'azure_functions_worker'], + [sys.executable, '-m', 'mypy', '-m', 'azure_functions_worker'], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, From b376b46208c3a15e8948dd3831ea61cbb79b17be Mon Sep 17 00:00:00 2001 From: hallvictoria Date: Fri, 18 Apr 2025 10:36:38 -0500 Subject: [PATCH 34/42] format --- tests/unittests/test_code_quality.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unittests/test_code_quality.py b/tests/unittests/test_code_quality.py index fa3ebffab..54d1cc725 100644 --- a/tests/unittests/test_code_quality.py +++ b/tests/unittests/test_code_quality.py @@ -17,7 +17,7 @@ def test_mypy(self): try: subprocess.run( - [sys.executable, '-m', 'mypy', '-m', 'azure_functions_worker'], + [sys.executable, '-m', 'mypy', '-m', 'azure_functions_worker'], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, From 2419e7806bc56af9e43d065e9847a6782ab6195f Mon Sep 17 00:00:00 2001 From: hallvictoria Date: Fri, 18 Apr 2025 11:03:15 -0500 Subject: [PATCH 35/42] lint --- tests/unittests/test_code_quality.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unittests/test_code_quality.py b/tests/unittests/test_code_quality.py index 54d1cc725..499ed577d 100644 --- a/tests/unittests/test_code_quality.py +++ b/tests/unittests/test_code_quality.py @@ -43,7 +43,8 @@ def test_flake8(self): try: subprocess.run( - [sys.executable, '-m', 'flake8', '--config', str(config_path)], + [sys.executable, '-m', 'flake8', '--config', str(config_path), + 'azure_functions_worker',], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, From 661de36cd015a3867faf184a9b20ce62e05857fc Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Tue, 22 Apr 2025 13:23:44 -0500 Subject: [PATCH 36/42] fix unittest dir for proxy --- eng/templates/jobs/ci-unit-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eng/templates/jobs/ci-unit-tests.yml b/eng/templates/jobs/ci-unit-tests.yml index 46e1e8149..11acf05cd 100644 --- a/eng/templates/jobs/ci-unit-tests.yml +++ b/eng/templates/jobs/ci-unit-tests.yml @@ -44,7 +44,7 @@ jobs: if [ "$PY_MINOR" -ge 13 ]; then echo "Running proxy_worker tests (Python >= 3.13)..." python -m pytest -q -n auto --dist loadfile --reruns 4 --instafail \ - --cov=./azure_functions_worker --cov-report xml --cov-branch tests/unittests_proxy + --cov=./proxy_worker --cov-report xml --cov-branch tests/unittest_proxy else echo "Running unittests (Python < 3.13)..." python -m pytest -q -n auto --dist loadfile --reruns 4 --instafail \ From 7fccc3afc0457c286fb3bc637839d5d3ee5b02a4 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Tue, 22 Apr 2025 15:24:21 -0500 Subject: [PATCH 37/42] set env variable --- eng/templates/official/jobs/ci-e2e-tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/eng/templates/official/jobs/ci-e2e-tests.yml b/eng/templates/official/jobs/ci-e2e-tests.yml index 0b03b71a2..1a45e06ce 100644 --- a/eng/templates/official/jobs/ci-e2e-tests.yml +++ b/eng/templates/official/jobs/ci-e2e-tests.yml @@ -160,4 +160,5 @@ jobs: AzureWebJobsEventGridTopicUri: $(EVENTGRID_URI) AzureWebJobsEventGridConnectionKey: $(EVENTGRID_CONNECTION) skipTest: $(skipTest) + PYAZURE_WEBHOST_DEBUG: true displayName: "Running $(PYTHON_VERSION) Python E2E Tests" From df125fd535dcc759234015340bbad4e9ee248624 Mon Sep 17 00:00:00 2001 From: hallvictoria Date: Wed, 23 Apr 2025 09:51:37 -0500 Subject: [PATCH 38/42] update pyproject to use real deps --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ce8a5063b..156bb0dfd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,8 +38,8 @@ dependencies = [ "grpcio ~=1.59.0; python_version >= '3.8' and python_version < '3.13'", "grpcio~=1.70.0; python_version >= '3.13'", "azurefunctions-extensions-base; python_version >= '3.8'", - "test-worker==1.0.0a38; python_version >= '3.13'", - "test-worker-v1==1.0.0a11; python_version >= '3.13'" + "azure-functions-runtime==1.0.0a1; python_version >= '3.13'", + "azure-functions-runtime-v1==1.0.0a1; python_version >= '3.13'" ] [project.urls] From 961370601d08134d6e9781d19202813a518c8da3 Mon Sep 17 00:00:00 2001 From: hallvictoria Date: Wed, 23 Apr 2025 10:16:19 -0500 Subject: [PATCH 39/42] bump to a2 --- pyproject.toml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 156bb0dfd..578cf130d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,8 @@ classifiers = [ "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Operating System :: MacOS :: MacOS X", @@ -38,8 +40,8 @@ dependencies = [ "grpcio ~=1.59.0; python_version >= '3.8' and python_version < '3.13'", "grpcio~=1.70.0; python_version >= '3.13'", "azurefunctions-extensions-base; python_version >= '3.8'", - "azure-functions-runtime==1.0.0a1; python_version >= '3.13'", - "azure-functions-runtime-v1==1.0.0a1; python_version >= '3.13'" + "azure-functions-runtime==1.0.0a2; python_version >= '3.13'", + "azure-functions-runtime-v1==1.0.0a2; python_version >= '3.13'" ] [project.urls] From c9be88282855b9bfe27490a37a9bfd87d5646f79 Mon Sep 17 00:00:00 2001 From: hallvictoria Date: Wed, 23 Apr 2025 11:38:30 -0500 Subject: [PATCH 40/42] bump v2 to a3 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 578cf130d..69467d1a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ dependencies = [ "grpcio ~=1.59.0; python_version >= '3.8' and python_version < '3.13'", "grpcio~=1.70.0; python_version >= '3.13'", "azurefunctions-extensions-base; python_version >= '3.8'", - "azure-functions-runtime==1.0.0a2; python_version >= '3.13'", + "azure-functions-runtime==1.0.0a3; python_version >= '3.13'", "azure-functions-runtime-v1==1.0.0a2; python_version >= '3.13'" ] From 5ac75e1fd2f3411ed54166e1fe4487c1536725b3 Mon Sep 17 00:00:00 2001 From: Gavin Aguiar Date: Wed, 23 Apr 2025 16:07:49 -0500 Subject: [PATCH 41/42] Import v2 by default for LC --- proxy_worker/dispatcher.py | 72 ++++++++++++++++---------------------- 1 file changed, 30 insertions(+), 42 deletions(-) diff --git a/proxy_worker/dispatcher.py b/proxy_worker/dispatcher.py index 839ef9c7a..92af7098c 100644 --- a/proxy_worker/dispatcher.py +++ b/proxy_worker/dispatcher.py @@ -39,8 +39,8 @@ class ContextEnabledTask(asyncio.Task): AZURE_INVOCATION_ID = '__azure_function_invocation_id__' - def __init__(self, coro, loop, context=None): - super().__init__(coro, loop=loop, context=context) + def __init__(self, coro, loop, context=None, **kwargs): + super().__init__(coro, loop=loop, context=context, **kwargs) current_task = asyncio.current_task(loop) if current_task is not None: @@ -279,8 +279,8 @@ async def dispatch_forever(self): # sourcery skip: swap-if-expression # In Python 3.11+, constructing a task has an optional context # parameter. Allow for this param to be passed to ContextEnabledTask self._loop.set_task_factory( - lambda loop, coro, context=None: ContextEnabledTask( - coro, loop=loop, context=context)) + lambda loop, coro, context=None, **kwargs: ContextEnabledTask( + coro, loop=loop, context=context, **kwargs)) # Detach console logging before enabling GRPC channel logging logger.info('Detaching console logging.') @@ -369,24 +369,11 @@ def tp_max_workers_validator(value: str) -> bool: # We can box the app setting as int for earlier python versions. return int(max_workers) if max_workers else None - async def _handle__worker_init_request(self, request): - logger.info('Received WorkerInitRequest, ' - 'python version %s, ' - 'worker version %s, ' - 'request ID %s. ' - 'To enable debug level logging, please refer to ' - 'https://aka.ms/python-enable-debug-logging', - sys.version, - VERSION, - self.request_id) - - if DependencyManager.should_load_cx_dependencies(): - DependencyManager.prioritize_customer_dependencies() - + @staticmethod + def reload_library_worker(directory: str): global _library_worker - directory = request.worker_init_request.function_app_directory - v2_directory = os.path.join(directory, get_script_file_name()) - if os.path.exists(v2_directory): + v2_scriptfile = os.path.join(directory, get_script_file_name()) + if os.path.exists(v2_scriptfile): try: import azure_functions_worker_v2 # NoQA _library_worker = azure_functions_worker_v2 @@ -405,6 +392,26 @@ async def _handle__worker_init_request(self, request): logger.debug("azure_functions_worker_v1 library not found: %s", traceback.format_exc()) + async def _handle__worker_init_request(self, request): + logger.info('Received WorkerInitRequest, ' + 'python version %s, ' + 'worker version %s, ' + 'request ID %s. ' + 'To enable debug level logging, please refer to ' + 'https://aka.ms/python-enable-debug-logging', + sys.version, + VERSION, + self.request_id) + + if DependencyManager.is_in_linux_consumption(): + import azure_functions_worker_v2 + + if DependencyManager.should_load_cx_dependencies(): + DependencyManager.prioritize_customer_dependencies() + + directory = request.worker_init_request.function_app_directory + self.reload_library_worker(directory) + init_request = WorkerRequest(name="WorkerInitRequest", request=request, properties={"protos": protos, @@ -427,28 +434,9 @@ async def _handle__function_environment_reload_request(self, request): func_env_reload_request = \ request.function_environment_reload_request directory = func_env_reload_request.function_app_directory - DependencyManager.prioritize_customer_dependencies(directory) - global _library_worker - v2_directory = os.path.join(directory, get_script_file_name()) - if os.path.exists(v2_directory): - try: - import azure_functions_worker_v2 # NoQA - _library_worker = azure_functions_worker_v2 - logger.debug("azure_functions_worker_v2 import succeeded: %s", - _library_worker.__file__) - except ImportError: - logger.warning("azure_functions_worker_v2 library not found: %s", - traceback.format_exc()) - else: - try: - import azure_functions_worker_v1 # NoQA - _library_worker = azure_functions_worker_v1 - logger.debug("azure_functions_worker_v1 import succeeded: %s", - _library_worker.__file__) # type: ignore[union-attr] - except ImportError: - logger.warning("azure_functions_worker_v1 library not found: %s", - traceback.format_exc()) + DependencyManager.prioritize_customer_dependencies(directory) + self.reload_library_worker(directory) env_reload_request = WorkerRequest(name="FunctionEnvironmentReloadRequest", request=request, From dec9d34c4171cf3e4a5b701a98cdb2d1a78fb58e Mon Sep 17 00:00:00 2001 From: Gavin Aguiar Date: Thu, 24 Apr 2025 15:01:56 -0500 Subject: [PATCH 42/42] Refactoring and minor fixes --- proxy_worker/dispatcher.py | 15 ++++++++++----- proxy_worker/start_worker.py | 24 +++++++++--------------- proxy_worker/utils/common.py | 8 +++++--- proxy_worker/utils/constants.py | 2 ++ proxy_worker/utils/dependency.py | 7 +++++-- python/prodV4/worker.config.json | 2 +- 6 files changed, 32 insertions(+), 26 deletions(-) diff --git a/proxy_worker/dispatcher.py b/proxy_worker/dispatcher.py index 92af7098c..257b68b14 100644 --- a/proxy_worker/dispatcher.py +++ b/proxy_worker/dispatcher.py @@ -15,7 +15,6 @@ from typing import Any, Optional import grpc - from proxy_worker import protos from proxy_worker.logging import ( CONSOLE_LOG_PREFIX, @@ -25,11 +24,17 @@ is_system_log_category, logger, ) -from proxy_worker.utils.common import get_app_setting -from proxy_worker.utils.common import is_envvar_true, get_script_file_name -from proxy_worker.utils.constants import PYTHON_ENABLE_DEBUG_LOGGING, \ - PYTHON_THREADPOOL_THREAD_COUNT +from proxy_worker.utils.common import ( + get_app_setting, + get_script_file_name, + is_envvar_true, +) +from proxy_worker.utils.constants import ( + PYTHON_ENABLE_DEBUG_LOGGING, + PYTHON_THREADPOOL_THREAD_COUNT, +) from proxy_worker.version import VERSION + from .utils.dependency import DependencyManager # Library worker import reloaded in init and reload request diff --git a/proxy_worker/start_worker.py b/proxy_worker/start_worker.py index f5dc9a6a0..d468cef69 100644 --- a/proxy_worker/start_worker.py +++ b/proxy_worker/start_worker.py @@ -5,6 +5,7 @@ import argparse import traceback +_GRPC_CONNECTION_TIMEOUT = 5.0 def parse_args(): parser = argparse.ArgumentParser( @@ -17,24 +18,17 @@ def parse_args(): help='id for the worker') parser.add_argument('--requestId', dest='request_id', help='id of the request') - parser.add_argument('--log-level', type=str, default='INFO', - choices=['TRACE', 'INFO', 'WARNING', 'ERROR'], - help="log level: 'TRACE', 'INFO', 'WARNING', " - "or 'ERROR'") - parser.add_argument('--log-to', type=str, default=None, - help='log destination: stdout, stderr, ' - 'syslog, or a file path') parser.add_argument('--grpcMaxMessageLength', type=int, dest='grpc_max_msg_len') parser.add_argument('--functions-uri', dest='functions_uri', type=str, help='URI with IP Address and Port used to' ' connect to the Host via gRPC.') - parser.add_argument('--functions-request-id', dest='functions_request_id', - type=str, help='Request ID used for gRPC communication ' - 'with the Host.') parser.add_argument('--functions-worker-id', dest='functions_worker_id', type=str, help='Worker ID assigned to this language worker.') + parser.add_argument('--functions-request-id', dest='functions_request_id', + type=str, help='Request ID used for gRPC communication ' + 'with the Host.') parser.add_argument('--functions-grpc-max-message-length', type=int, dest='functions_grpc_max_msg_len', help='Max grpc_local message length for Functions') @@ -52,12 +46,12 @@ def start(): from .logging import error_logger, logger args = parse_args() - logging.setup(log_level=args.log_level, log_destination=args.log_to) + logging.setup(log_level="INFO", log_destination=None) logger.info("Args: %s", args) - logger.info('Starting proxy worker.') - logger.info('Worker ID: %s, Request ID: %s, Host Address: %s:%s', - args.worker_id, args.request_id, args.host, args.port) + logger.info( + 'Starting proxy worker. Worker ID: %s, Request ID: %s, Host Address: %s:%s', + args.worker_id, args.request_id, args.host, args.port) try: return asyncio.run(start_async( @@ -75,7 +69,7 @@ async def start_async(host, port, worker_id, request_id): disp = await dispatcher.Dispatcher.connect(host=host, port=port, worker_id=worker_id, request_id=request_id, - connect_timeout=5.0) + connect_timeout=_GRPC_CONNECTION_TIMEOUT) await disp.dispatch_forever() diff --git a/proxy_worker/utils/common.py b/proxy_worker/utils/common.py index 5bcb9cd0c..5b2f1e98f 100644 --- a/proxy_worker/utils/common.py +++ b/proxy_worker/utils/common.py @@ -2,10 +2,12 @@ # Licensed under the MIT License. import os -from typing import Optional, Callable +from typing import Callable, Optional -from proxy_worker.utils.constants import PYTHON_SCRIPT_FILE_NAME_DEFAULT, \ - PYTHON_SCRIPT_FILE_NAME +from proxy_worker.utils.constants import ( + PYTHON_SCRIPT_FILE_NAME, + PYTHON_SCRIPT_FILE_NAME_DEFAULT, +) def get_app_setting( diff --git a/proxy_worker/utils/constants.py b/proxy_worker/utils/constants.py index abcf84354..c5e0dd2ab 100644 --- a/proxy_worker/utils/constants.py +++ b/proxy_worker/utils/constants.py @@ -5,9 +5,11 @@ PYTHON_ENABLE_DEBUG_LOGGING = "PYTHON_ENABLE_DEBUG_LOGGING" PYTHON_THREADPOOL_THREAD_COUNT = "PYTHON_THREADPOOL_THREAD_COUNT" +# Container constants CONTAINER_NAME = "CONTAINER_NAME" AZURE_WEBJOBS_SCRIPT_ROOT = "AzureWebJobsScriptRoot" # new programming model default script file name PYTHON_SCRIPT_FILE_NAME = "PYTHON_SCRIPT_FILE_NAME" PYTHON_SCRIPT_FILE_NAME_DEFAULT = "function_app.py" + diff --git a/proxy_worker/utils/dependency.py b/proxy_worker/utils/dependency.py index 3cde31430..b5b07dbd5 100644 --- a/proxy_worker/utils/dependency.py +++ b/proxy_worker/utils/dependency.py @@ -6,9 +6,9 @@ from types import ModuleType from typing import List, Optional +from ..logging import logger from .common import is_envvar_true from .constants import AZURE_WEBJOBS_SCRIPT_ROOT, CONTAINER_NAME -from ..logging import logger class DependencyManager: @@ -295,7 +295,10 @@ def _remove_module_cache(path: str): # Both of these has the module path placed in __path__ property # The property .__path__ can be None or does not exist in module try: - module_paths = set(getattr(module, '__path__', None) or []) + # Safely check for __path__ and __file__ existence + module_paths = set() + if hasattr(module, '__path__') and module.__path__: + module_paths.update(module.__path__) if hasattr(module, '__file__') and module.__file__: module_paths.add(module.__file__) diff --git a/python/prodV4/worker.config.json b/python/prodV4/worker.config.json index d01e5fe1c..eaa3c50cb 100644 --- a/python/prodV4/worker.config.json +++ b/python/prodV4/worker.config.json @@ -1,7 +1,7 @@ { "description":{ "language":"python", - "defaultRuntimeVersion":"3.11", + "defaultRuntimeVersion":"3.12", "supportedOperatingSystems":["LINUX", "OSX", "WINDOWS"], "supportedRuntimeVersions":["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"], "supportedArchitectures":["X64", "X86", "Arm64"],