diff --git a/README.md b/README.md index 8eb99be63..1d0c8ebba 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,15 @@ |--------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | dev | [![Build Status](https://img.shields.io/azure-devops/build/azfunc/public/658/dev)](https://azfunc.visualstudio.com/public/_build/latest?definitionId=658&branchName=dev) | [![codecov](https://codecov.io/gh/Azure/azure-functions-python-worker/branch/dev/graph/badge.svg)](https://codecov.io/gh/Azure/azure-functions-python-worker) | [![Test Status](https://img.shields.io/azure-devops/build/azfunc/public/658/dev)](https://azfunc.visualstudio.com/public/_build/latest?definitionId=658&branchName=dev) | -Python support for Azure Functions is based on Python 3.10, 3.11, 3.12, and 3.13 serverless hosting on Linux and the Functions 4.0 runtime. +Python support for Azure Functions is based on Python 3.10, 3.11, 3.12, 3.13, and 3.14 serverless hosting on Linux and the Functions 4.0 runtime. Here is the current status of Python in Azure Functions: What are the supported Python versions? -| Azure Functions Runtime | Python 3.10 | Python 3.11 | Python 3.12 | Python 3.13 | -|----------------------------------|------------|------------|-------------|-------------| -| Azure Functions 4.0 | ✔ | ✔ | ✔ | ✔ | +| Azure Functions Runtime | Python 3.10 | Python 3.11 | Python 3.12 | Python 3.13 | Python 3.14 | +|----------------------------------|-------------|-------------|-------------|-------------|-------------| +| Azure Functions 4.0 | ✔ | ✔ | ✔ | ✔ | ✔ | For information about Azure Functions Runtime, please refer to [Azure Functions runtime versions overview](https://docs.microsoft.com/en-us/azure/azure-functions/functions-versions) page. diff --git a/runtimes/v1/README.md b/runtimes/v1/README.md index 480c448b7..d5a6a96c4 100644 --- a/runtimes/v1/README.md +++ b/runtimes/v1/README.md @@ -4,15 +4,15 @@ |--------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | dev | [![Build Status](https://img.shields.io/azure-devops/build/azfunc/public/658/dev)](https://azfunc.visualstudio.com/public/_build/latest?definitionId=658&branchName=dev) | [![codecov](https://codecov.io/gh/Azure/azure-functions-python-worker/branch/dev/graph/badge.svg)](https://codecov.io/gh/Azure/azure-functions-python-worker) | [![Test Status](https://img.shields.io/azure-devops/build/azfunc/public/658/dev)](https://azfunc.visualstudio.com/public/_build/latest?definitionId=658&branchName=dev) | -Python support for Azure Functions is based on Python 3.13 serverless hosting on Linux and the Functions 4.0 runtime. +Python support for Azure Functions is based on Python 3.13 and 3.14 serverless hosting on Linux and the Functions 4.0 runtime. Here is the current status of Python in Azure Functions: What are the supported Python versions? -| Azure Functions Runtime | Python 3.13 | -|----------------------------------|-------------| -| Azure Functions 4.0 | ✔ | +| Azure Functions Runtime | Python 3.13 | Python 3.14 | +|----------------------------------|-------------|-------------| +| Azure Functions 4.0 | ✔ | ✔ | For information about Azure Functions Runtime, please refer to [Azure Functions runtime versions overview](https://docs.microsoft.com/en-us/azure/azure-functions/functions-versions) page. diff --git a/runtimes/v2/README.md b/runtimes/v2/README.md index 5d1bb195c..d5a6a96c4 100644 --- a/runtimes/v2/README.md +++ b/runtimes/v2/README.md @@ -4,15 +4,15 @@ |--------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | dev | [![Build Status](https://img.shields.io/azure-devops/build/azfunc/public/658/dev)](https://azfunc.visualstudio.com/public/_build/latest?definitionId=658&branchName=dev) | [![codecov](https://codecov.io/gh/Azure/azure-functions-python-worker/branch/dev/graph/badge.svg)](https://codecov.io/gh/Azure/azure-functions-python-worker) | [![Test Status](https://img.shields.io/azure-devops/build/azfunc/public/658/dev)](https://azfunc.visualstudio.com/public/_build/latest?definitionId=658&branchName=dev) | -Python support for Azure Functions is based on Python 3.13 serverless hosting on Linux and the Functions 4.0 runtime. +Python support for Azure Functions is based on Python 3.13 and 3.14 serverless hosting on Linux and the Functions 4.0 runtime. Here is the current status of Python in Azure Functions: What are the supported Python versions? -| Azure Functions Runtime | Python 3.13 | -|----------------------------------|-------------| -| Azure Functions 4.0 | ✔ | +| Azure Functions Runtime | Python 3.13 | Python 3.14 | +|----------------------------------|-------------|-------------| +| Azure Functions 4.0 | ✔ | ✔ | For information about Azure Functions Runtime, please refer to [Azure Functions runtime versions overview](https://docs.microsoft.com/en-us/azure/azure-functions/functions-versions) page. diff --git a/runtimes/v2/azure_functions_runtime/bindings/meta.py b/runtimes/v2/azure_functions_runtime/bindings/meta.py index 21043b22d..3c429aece 100644 --- a/runtimes/v2/azure_functions_runtime/bindings/meta.py +++ b/runtimes/v2/azure_functions_runtime/bindings/meta.py @@ -27,6 +27,11 @@ BINDING_REGISTRY = None DEFERRED_BINDING_REGISTRY = None +# Tracks whether the lazy load of azurefunctions.extensions.base has +# already been attempted (independent of success). This prevents repeated +# import attempts when the extension isn't installed. +_DEFERRED_BINDING_REGISTRY_LOADED = False + def _check_http_input_type_annotation(bind_name: str, pytype: type, is_deferred_binding: bool) -> bool: @@ -61,7 +66,10 @@ def load_binding_registry() -> None: not found, it loads the builtin. If the BINDING_REGISTRY is None, azure-functions hasn't been loaded in properly. - Tries to load the base extension. + Note: ``azurefunctions.extensions.base`` is NOT eagerly imported + here. Only apps that use deferred bindings or HTTP v2 actually + need it. The deferred binding registry is populated lazily on first + use via :func:`_get_deferred_binding_registry`. """ func = sys.modules.get('azure.functions') @@ -82,9 +90,45 @@ def load_binding_registry() -> None: sys.path, sys.modules, os.path.exists(CUSTOMER_PACKAGES_PATH)) + +def _get_deferred_binding_registry(): + """ + Lazily resolve the deferred binding registry from + ``azurefunctions.extensions.base``. + + The first call attempts the import. On success the registry is + cached in :data:`DEFERRED_BINDING_REGISTRY` and returned on every + subsequent call without re-importing. If the extension is not + installed or the customer's code never imported it, the result is + cached as ``None`` and no further work is done. + + Deferred binding parameter types come from + ``azurefunctions.extensions.bindings.*`` packages, which all + transitively import ``azurefunctions.extensions.base``. So if the + base extension is not in ``sys.modules`` by the time we are asked + (always called after `function_app.py` has been imported), the + function app cannot be using deferred bindings. + + Tests may set :data:`DEFERRED_BINDING_REGISTRY` directly; that + value is honored. + """ + global DEFERRED_BINDING_REGISTRY, _DEFERRED_BINDING_REGISTRY_LOADED + + if DEFERRED_BINDING_REGISTRY is not None: + return DEFERRED_BINDING_REGISTRY + if _DEFERRED_BINDING_REGISTRY_LOADED: + return None + + # Short-circuit: if the customer hasn't (transitively) imported the + # base extension, they cannot be using deferred bindings. Skip the + # cold-import cost entirely. + if 'azurefunctions.extensions.base' not in sys.modules: + _DEFERRED_BINDING_REGISTRY_LOADED = True + return None + + _DEFERRED_BINDING_REGISTRY_LOADED = True try: import azurefunctions.extensions.base as clients - global DEFERRED_BINDING_REGISTRY DEFERRED_BINDING_REGISTRY = clients.get_binding_registry() except ImportError: logger.debug('Base extension not found. ' @@ -92,6 +136,7 @@ def load_binding_registry() -> None: 'Sys Module: %s, python-packages Path exists: %s.', sys.version_info.minor, sys.path, sys.modules, os.path.exists(CUSTOMER_PACKAGES_PATH)) + return DEFERRED_BINDING_REGISTRY def get_binding(bind_name: str, @@ -107,7 +152,9 @@ def get_binding(bind_name: str, if binding is None and not is_deferred_binding: binding = BINDING_REGISTRY.get(bind_name) # type: ignore if binding is None and is_deferred_binding: - binding = DEFERRED_BINDING_REGISTRY.get(bind_name) # type: ignore + deferred_registry = _get_deferred_binding_registry() + if deferred_registry is not None: + binding = deferred_registry.get(bind_name) if binding is None: binding = GenericBinding return binding @@ -270,8 +317,9 @@ def check_deferred_bindings_enabled(param_anno: Union[type, None], The first bool represents if deferred bindings is enabled at a fx level The second represents if the current binding is deferred binding """ - if (DEFERRED_BINDING_REGISTRY is not None - and DEFERRED_BINDING_REGISTRY.check_supported_type(param_anno)): + deferred_registry = _get_deferred_binding_registry() + if (deferred_registry is not None + and deferred_registry.check_supported_type(param_anno)): return True, True else: return deferred_bindings_enabled, False @@ -284,13 +332,15 @@ def get_deferred_raw_bindings(indexed_function, input_types): the defined binding type and if deferred bindings is enabled for that binding. """ - raw_bindings, bindings_logs = DEFERRED_BINDING_REGISTRY.get_raw_bindings( + deferred_registry = _get_deferred_binding_registry() + raw_bindings, bindings_logs = deferred_registry.get_raw_bindings( indexed_function, input_types) return raw_bindings, bindings_logs def get_settlement_client(): - return DEFERRED_BINDING_REGISTRY.get(SERVICE_BUS_CLIENT_NAME).get_client() + deferred_registry = _get_deferred_binding_registry() + return deferred_registry.get(SERVICE_BUS_CLIENT_NAME).get_client() def validate_settlement_param(params: dict, @@ -318,7 +368,10 @@ def validate_settlement_param(params: dict, try: param_type = annotations.get(settlement_param) # Check if the type is a supported type for the settlement client - if DEFERRED_BINDING_REGISTRY.check_supported_grpc_client_type(param_type): + deferred_registry = _get_deferred_binding_registry() + if (deferred_registry is not None + and deferred_registry.check_supported_grpc_client_type( + param_type)): return settlement_param except Exception: param_type = None diff --git a/runtimes/v2/azure_functions_runtime/http_v2.py b/runtimes/v2/azure_functions_runtime/http_v2.py index 9f79c75c6..de323c38d 100644 --- a/runtimes/v2/azure_functions_runtime/http_v2.py +++ b/runtimes/v2/azure_functions_runtime/http_v2.py @@ -4,6 +4,7 @@ import asyncio import importlib import socket +import sys from typing import Any, Dict @@ -277,6 +278,11 @@ def ext_base(cls): @classmethod def _check_http_v2_enabled(cls): + # HTTP v2 support requires azurefunctions.extensions.base. If the + # function_app.py did not transitively import that package, HTTP + # v2 cannot be in use, so skip the cold-import cost + if 'azurefunctions.extensions.base' not in sys.modules: + return False try: # Attempt to import the base extension module import azurefunctions.extensions.base as ext_base diff --git a/runtimes/v2/tests/unittests/test_lazy_base_extension.py b/runtimes/v2/tests/unittests/test_lazy_base_extension.py new file mode 100644 index 000000000..b715a1d1a --- /dev/null +++ b/runtimes/v2/tests/unittests/test_lazy_base_extension.py @@ -0,0 +1,410 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +"""Unit tests for the lazy loading of ``azurefunctions.extensions.base``. + +The v2 runtime defers importing the base extension until first use to +avoid paying its (200+ ms) cold-import cost for apps that don't use +deferred bindings or HTTP v2. These tests pin down that contract: + +* ``load_binding_registry`` must NOT import the base extension. +* ``_get_deferred_binding_registry`` must short-circuit to ``None`` + when the base extension is not already in ``sys.modules``. +* ``_get_deferred_binding_registry`` caches its result so the import + attempt happens at most once. +* ``HttpV2Registry._check_http_v2_enabled`` must return ``False`` + without importing the base extension when it is not in + ``sys.modules``. +* The deferred-binding consumers (``get_binding``, + ``check_deferred_bindings_enabled``) gracefully fall back when the + registry is unavailable. +""" +import sys +import types +import unittest +from unittest import mock + +from azure_functions_runtime.bindings import meta +from azure_functions_runtime.http_v2 import HttpV2Registry + + +def _reset_meta_state(): + """Restore meta module state between tests.""" + meta.DEFERRED_BINDING_REGISTRY = None + meta._DEFERRED_BINDING_REGISTRY_LOADED = False + + +def _reset_http_v2_state(): + """Restore HttpV2Registry class state between tests.""" + HttpV2Registry._http_v2_enabled = False + HttpV2Registry._ext_base = None + HttpV2Registry._http_v2_enabled_checked = False + + +class TestLoadBindingRegistryNoEagerExtImport(unittest.TestCase): + """``load_binding_registry`` must not import the base extension.""" + + def setUp(self): + _reset_meta_state() + + def tearDown(self): + _reset_meta_state() + + def test_load_binding_registry_does_not_import_base_extension(self): + # The base extension must remain absent from sys.modules after + # load_binding_registry runs. We patch the import machinery to + # raise if anything tries to import azurefunctions.extensions.base + # during this call, which catches even transitive imports. + real_import = __builtins__['__import__'] if isinstance( + __builtins__, dict) else __builtins__.__import__ + seen_base_import = [] + + def tracking_import(name, *args, **kwargs): + if name == 'azurefunctions.extensions.base' or name.startswith( + 'azurefunctions.extensions.base.'): + seen_base_import.append(name) + return real_import(name, *args, **kwargs) + + # Pop base extension from sys.modules so any import would be + # observed (no cached version). + removed = { + k: sys.modules.pop(k) + for k in list(sys.modules) + if k == 'azurefunctions.extensions.base' + or k.startswith('azurefunctions.extensions.base.') + } + try: + with mock.patch('builtins.__import__', side_effect=tracking_import): + meta.load_binding_registry() + finally: + sys.modules.update(removed) + + self.assertEqual( + seen_base_import, [], + msg='load_binding_registry should not import ' + 'azurefunctions.extensions.base. Saw imports: ' + f'{seen_base_import}', + ) + + def test_load_binding_registry_populates_binding_registry(self): + # The customer-facing azure.functions registry must still be + # populated unconditionally — only the base extension load is + # deferred. + meta.BINDING_REGISTRY = None + meta.load_binding_registry() + self.assertIsNotNone( + meta.BINDING_REGISTRY, + msg='BINDING_REGISTRY must be populated even though the ' + 'base extension load is deferred.', + ) + + +class TestGetDeferredBindingRegistry(unittest.TestCase): + """Behaviour of the lazy ``_get_deferred_binding_registry`` helper.""" + + def setUp(self): + _reset_meta_state() + # Stash any pre-existing base extension import so we can control + # sys.modules presence per-test. + self._stashed_modules = { + k: sys.modules.pop(k) + for k in list(sys.modules) + if k == 'azurefunctions.extensions.base' + or k.startswith('azurefunctions.extensions.base.') + } + + def tearDown(self): + _reset_meta_state() + sys.modules.update(self._stashed_modules) + + @staticmethod + def _install_fake_base_extension(fake_module): + """Place fake_module at sys.modules['azurefunctions.extensions.base'] + AND wire up its parent packages so ``import azurefunctions.extensions.base + as clients`` resolves ``clients`` to fake_module without touching disk. + + The ``as`` form of import does attribute lookup on the parent + package after ``__import__`` returns, so the parents must be real + modules with the expected attribute set (MagicMocks autogenerate + a fresh child for any attribute access, which would shadow our + fake_module). + + Returns a list of keys that were inserted so the caller can pop + them at teardown. + """ + inserted = [] + # Build (or stash) the namespace-package chain. + if 'azurefunctions' not in sys.modules: + sys.modules['azurefunctions'] = types.ModuleType('azurefunctions') + inserted.append('azurefunctions') + if 'azurefunctions.extensions' not in sys.modules: + sys.modules['azurefunctions.extensions'] = types.ModuleType( + 'azurefunctions.extensions') + inserted.append('azurefunctions.extensions') + # Wire attributes so attribute traversal returns the right child. + sys.modules['azurefunctions'].extensions = \ + sys.modules['azurefunctions.extensions'] + sys.modules['azurefunctions.extensions'].base = fake_module + sys.modules['azurefunctions.extensions.base'] = fake_module + inserted.append('azurefunctions.extensions.base') + return inserted + + @staticmethod + def _pop_fake_modules(keys): + for k in keys: + sys.modules.pop(k, None) + + def test_short_circuits_when_base_extension_not_in_sys_modules(self): + # Customer hasn't loaded any azurefunctions.extensions.* package, + # so the helper must return None without attempting an import. + self.assertNotIn('azurefunctions.extensions.base', sys.modules) + + def fail_on_import(name, *args, **kwargs): + if name == 'azurefunctions.extensions.base': + raise AssertionError( + 'Helper attempted to import the base extension despite ' + 'the sys.modules short-circuit') + return _real_import(name, *args, **kwargs) + + _real_import = __builtins__['__import__'] if isinstance( + __builtins__, dict) else __builtins__.__import__ + + with mock.patch('builtins.__import__', side_effect=fail_on_import): + result = meta._get_deferred_binding_registry() + + self.assertIsNone(result) + self.assertTrue(meta._DEFERRED_BINDING_REGISTRY_LOADED) + + def test_loads_registry_when_base_extension_already_imported(self): + # Simulate the customer's code having imported the base extension + # transitively (e.g. via azurefunctions.extensions.bindings.blob). + fake_registry = mock.Mock(name='fake_registry') + fake_module = mock.MagicMock() + fake_module.get_binding_registry.return_value = fake_registry + keys = self._install_fake_base_extension(fake_module) + + try: + result = meta._get_deferred_binding_registry() + finally: + self._pop_fake_modules(keys) + + self.assertIs(result, fake_registry) + self.assertIs(meta.DEFERRED_BINDING_REGISTRY, fake_registry) + + def test_result_is_cached_on_success(self): + fake_registry = mock.Mock(name='fake_registry') + fake_module = mock.MagicMock() + fake_module.get_binding_registry.return_value = fake_registry + keys = self._install_fake_base_extension(fake_module) + + try: + first = meta._get_deferred_binding_registry() + # Drop the module again; cached value must still be returned. + self._pop_fake_modules(keys) + second = meta._get_deferred_binding_registry() + finally: + self._pop_fake_modules(keys) + + self.assertIs(first, second) + # get_binding_registry should only be invoked once across calls. + fake_module.get_binding_registry.assert_called_once() + + def test_negative_result_is_cached(self): + # When the short-circuit fires, subsequent calls must NOT retry + # the import even if the customer later loads something that + # would pull in the base extension. + self.assertNotIn('azurefunctions.extensions.base', sys.modules) + + first = meta._get_deferred_binding_registry() + self.assertIsNone(first) + self.assertTrue(meta._DEFERRED_BINDING_REGISTRY_LOADED) + + # Now pretend the base extension has appeared. The cached "no" + # result wins to avoid repeated import work in the hot path. + fake_module = mock.MagicMock() + fake_module.get_binding_registry.return_value = mock.Mock() + keys = self._install_fake_base_extension(fake_module) + try: + second = meta._get_deferred_binding_registry() + finally: + self._pop_fake_modules(keys) + + self.assertIsNone(second) + fake_module.get_binding_registry.assert_not_called() + + def test_direct_assignment_to_registry_is_honored(self): + # Existing tests (e.g. test_deferred_bindings) set + # meta.DEFERRED_BINDING_REGISTRY directly. The helper must + # honor that value without re-importing. + sentinel = mock.Mock(name='preset') + meta.DEFERRED_BINDING_REGISTRY = sentinel + + def fail_on_import(name, *args, **kwargs): + if name == 'azurefunctions.extensions.base': + raise AssertionError( + 'Helper attempted to import despite preset registry') + return _real_import(name, *args, **kwargs) + + _real_import = __builtins__['__import__'] if isinstance( + __builtins__, dict) else __builtins__.__import__ + + with mock.patch('builtins.__import__', side_effect=fail_on_import): + result = meta._get_deferred_binding_registry() + + self.assertIs(result, sentinel) + + +class TestCheckDeferredBindingsEnabledFallback(unittest.TestCase): + """``check_deferred_bindings_enabled`` must not error when the + registry is unavailable; it must return the pass-through values.""" + + def setUp(self): + _reset_meta_state() + self._stashed_modules = { + k: sys.modules.pop(k) + for k in list(sys.modules) + if k == 'azurefunctions.extensions.base' + or k.startswith('azurefunctions.extensions.base.') + } + + def tearDown(self): + _reset_meta_state() + sys.modules.update(self._stashed_modules) + + def test_returns_passthrough_when_extension_unavailable(self): + # Common case for the regression we fixed: a function with a + # plain HttpRequest annotation triggers this call for each + # parameter during indexing. It must NOT load the extension. + self.assertNotIn('azurefunctions.extensions.base', sys.modules) + + enabled, is_deferred = meta.check_deferred_bindings_enabled( + str, deferred_bindings_enabled=False) + self.assertFalse(enabled) + self.assertFalse(is_deferred) + + enabled, is_deferred = meta.check_deferred_bindings_enabled( + str, deferred_bindings_enabled=True) + self.assertTrue(enabled) + self.assertFalse(is_deferred) + + self.assertNotIn( + 'azurefunctions.extensions.base', sys.modules, + msg='check_deferred_bindings_enabled triggered an import ' + 'of the base extension on a non-deferred path.', + ) + + +class TestGetBindingDeferredFallback(unittest.TestCase): + """``get_binding`` with ``is_deferred_binding=True`` must not crash + when the registry is unavailable.""" + + def setUp(self): + _reset_meta_state() + # Ensure the regular binding registry exists for non-deferred + # lookups (load_binding_registry populates it). + meta.load_binding_registry() + self._stashed_modules = { + k: sys.modules.pop(k) + for k in list(sys.modules) + if k == 'azurefunctions.extensions.base' + or k.startswith('azurefunctions.extensions.base.') + } + + def tearDown(self): + _reset_meta_state() + sys.modules.update(self._stashed_modules) + + def test_deferred_lookup_falls_back_to_generic(self): + # Without the extension installed, deferred lookups should + # silently return GenericBinding rather than throwing + # AttributeError on DEFERRED_BINDING_REGISTRY.get. + result = meta.get_binding( + 'unknown_deferred_binding', is_deferred_binding=True) + from azure_functions_runtime.bindings.generic import GenericBinding + self.assertIs(result, GenericBinding) + + +class TestHttpV2RegistryShortCircuit(unittest.TestCase): + """``HttpV2Registry._check_http_v2_enabled`` must not import the + base extension when it is not already in ``sys.modules``.""" + + def setUp(self): + _reset_http_v2_state() + self._stashed_modules = { + k: sys.modules.pop(k) + for k in list(sys.modules) + if k == 'azurefunctions.extensions.base' + or k.startswith('azurefunctions.extensions.base.') + } + self._inserted_keys = [] + + def tearDown(self): + _reset_http_v2_state() + for k in self._inserted_keys: + sys.modules.pop(k, None) + sys.modules.update(self._stashed_modules) + + def _install_fake_base_extension(self, fake_module): + """See TestGetDeferredBindingRegistry._install_fake_base_extension.""" + if 'azurefunctions' not in sys.modules: + sys.modules['azurefunctions'] = types.ModuleType('azurefunctions') + self._inserted_keys.append('azurefunctions') + if 'azurefunctions.extensions' not in sys.modules: + sys.modules['azurefunctions.extensions'] = types.ModuleType( + 'azurefunctions.extensions') + self._inserted_keys.append('azurefunctions.extensions') + sys.modules['azurefunctions'].extensions = \ + sys.modules['azurefunctions.extensions'] + sys.modules['azurefunctions.extensions'].base = fake_module + sys.modules['azurefunctions.extensions.base'] = fake_module + self._inserted_keys.append('azurefunctions.extensions.base') + + def test_returns_false_without_importing_when_extension_absent(self): + self.assertNotIn('azurefunctions.extensions.base', sys.modules) + + def fail_on_import(name, *args, **kwargs): + if name == 'azurefunctions.extensions.base': + raise AssertionError( + 'HttpV2Registry attempted to import the base extension ' + 'despite the sys.modules short-circuit') + return _real_import(name, *args, **kwargs) + + _real_import = __builtins__['__import__'] if isinstance( + __builtins__, dict) else __builtins__.__import__ + + with mock.patch('builtins.__import__', side_effect=fail_on_import): + self.assertFalse(HttpV2Registry.http_v2_enabled()) + + self.assertNotIn('azurefunctions.extensions.base', sys.modules) + + def test_uses_extension_when_already_imported(self): + # Simulate the customer's FastAPI extension having loaded the + # base extension; the registry must consult its feature checker. + fake_checker = mock.MagicMock() + fake_checker.http_v2_enabled.return_value = True + fake_module = mock.MagicMock() + fake_module.HttpV2FeatureChecker = fake_checker + self._install_fake_base_extension(fake_module) + + self.assertTrue(HttpV2Registry.http_v2_enabled()) + + fake_checker.http_v2_enabled.assert_called_once() + + def test_cached_result_skips_subsequent_checks(self): + # First call sets the cache; second call must not reconsult + # sys.modules or call the feature checker. + fake_checker = mock.MagicMock() + fake_checker.http_v2_enabled.return_value = True + fake_module = mock.MagicMock() + fake_module.HttpV2FeatureChecker = fake_checker + self._install_fake_base_extension(fake_module) + + first = HttpV2Registry.http_v2_enabled() + second = HttpV2Registry.http_v2_enabled() + + self.assertTrue(first) + self.assertTrue(second) + fake_checker.http_v2_enabled.assert_called_once() + + +if __name__ == '__main__': + unittest.main() diff --git a/workers/README.md b/workers/README.md index 927e52b39..d764ee991 100644 --- a/workers/README.md +++ b/workers/README.md @@ -4,16 +4,15 @@ |--------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | dev | [![Build Status](https://img.shields.io/azure-devops/build/azfunc/public/658/dev)](https://azfunc.visualstudio.com/public/_build/latest?definitionId=658&branchName=dev) | [![codecov](https://codecov.io/gh/Azure/azure-functions-python-worker/branch/dev/graph/badge.svg)](https://codecov.io/gh/Azure/azure-functions-python-worker) | [![Test Status](https://img.shields.io/azure-devops/build/azfunc/public/658/dev)](https://azfunc.visualstudio.com/public/_build/latest?definitionId=658&branchName=dev) | -Python support for Azure Functions is based on Python 3.8, 3.9, 3.10, 3.11, 3.12, and 3.13 serverless hosting on Linux and the Functions 4.0 runtime. +Python support for Azure Functions is based on Python 3.10, 3.11, 3.12, 3.13, and 3.14 serverless hosting on Linux and the Functions 4.0 runtime. Here is the current status of Python in Azure Functions: What are the supported Python versions? -| Azure Functions Runtime | Python 3.8 | Python 3.9 | Python 3.10 | Python 3.11 | Python 3.12 | Python 3.13 | -|----------------------------------|------------|------------|-------------|-------------|-------------|-------------| -| Azure Functions 3.0 (deprecated) | ✔ | ✔ | - | - | - | - | -| Azure Functions 4.0 | ✔ | ✔ | ✔ | ✔ | ✔ | ✔ | +| Azure Functions Runtime | Python 3.10 | Python 3.11 | Python 3.12 | Python 3.13 | Python 3.14 | +|----------------------------------|--------------|-------------|-------------|-------------|-------------| +| Azure Functions 4.0 | ✔ | ✔ | ✔ | ✔ | ✔ | For information about Azure Functions Runtime, please refer to [Azure Functions runtime versions overview](https://docs.microsoft.com/en-us/azure/azure-functions/functions-versions) page.