diff --git a/.github/workflows/ci_ut_ext_base_workflow.yml b/.github/workflows/ci_ut_ext_base_workflow.yml new file mode 100644 index 0000000..96668a7 --- /dev/null +++ b/.github/workflows/ci_ut_ext_base_workflow.yml @@ -0,0 +1,61 @@ +name: UT CI Run for Python Extension Base + +on: + push: + branches: [ dev, master, main, release/* ] + paths: + - 'azure-functions-extension-base/**' + pull_request: + branches: [ dev, master, main, release/* ] + paths: + - 'azure-functions-extension-base/**' + +jobs: + build: + name: "Python Extension Base UT CI Run" + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: [ 3.8, 3.9, "3.10", "3.11" ] + permissions: read-all + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + working-directory: azure-functions-extension-base + run: | + python -m pip install --upgrade pip + python -m pip install -U -e .[dev] + + - name: Run Unit Tests + working-directory: azure-functions-extension-base + env: + AzureWebJobsStorage: ${{ secrets.AzureWebJobsStorage }} + run: | + python -m pytest -q --instafail --cov=. --cov-report xml --cov-branch tests + + - name: Upload Coverage to Codecov + uses: codecov/codecov-action@v3 + with: + file: ./azure-functions-extension-base/coverage.xml + flags: unittests + name: codecov + fail_ci_if_error: false + + - name: Notify dedicated teams channel + uses: jdcargile/ms-teams-notification@v1.4 + if: failure() + with: + github-token: ${{ github.token }} # this will use the runner's token. + ms-teams-webhook-uri: ${{ secrets.MS_TEAMS_WEBHOOK_URI }} + notification-summary: "Python Extension Base UT CI Failed for Python ${{ matrix.python-version }}" + notification-color: 17a2b8 + timezone: America/Denver + verbose-logging: false diff --git a/.github/workflows/ci_ut_ext_blob_workflow.yml b/.github/workflows/ci_ut_ext_blob_workflow.yml new file mode 100644 index 0000000..7db0cd8 --- /dev/null +++ b/.github/workflows/ci_ut_ext_blob_workflow.yml @@ -0,0 +1,64 @@ +name: UT CI Run for Python Extension Blob + +on: + push: + branches: [ dev, master, main, release/* ] + paths: + - 'azure-functions-extension-base/**' + - 'azure-functions-extension-blob/**' + pull_request: + branches: [ dev, master, main, release/* ] + paths: + - 'azure-functions-extension-base/**' + - 'azure-functions-extension-blob/**' + +jobs: + build: + name: "Python Extension Blob UT CI Run" + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: [ 3.9, "3.10", "3.11" ] + permissions: read-all + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + working-directory: azure-functions-extension-blob + run: | + python -m pip install --upgrade pip + python -m pip install -U -e .[dev] + python -m pip install -e ../azure-functions-extension-base + + - name: Run Unit Tests + working-directory: azure-functions-extension-blob + env: + AzureWebJobsStorage: ${{ secrets.AzureWebJobsStorage }} + run: | + python -m pytest -q --instafail --cov=. --cov-report xml --cov-branch tests + + - name: Upload Coverage to Codecov + uses: codecov/codecov-action@v3 + with: + file: ./azure-functions-extension-blob/coverage.xml + flags: unittests + name: codecov + fail_ci_if_error: false + + - name: Notify dedicated teams channel + uses: jdcargile/ms-teams-notification@v1.4 + if: failure() + with: + github-token: ${{ github.token }} # this will use the runner's token. + ms-teams-webhook-uri: ${{ secrets.MS_TEAMS_WEBHOOK_URI }} + notification-summary: "Python Extension Blob UT CI Failed for Python ${{ matrix.python-version }}" + notification-color: 17a2b8 + timezone: America/Denver + verbose-logging: false diff --git a/.github/workflows/ci_ut_ext_fastapi_workflow.yml b/.github/workflows/ci_ut_ext_fastapi_workflow.yml new file mode 100644 index 0000000..289b1ab --- /dev/null +++ b/.github/workflows/ci_ut_ext_fastapi_workflow.yml @@ -0,0 +1,62 @@ +name: UT CI Run for Python Extension FastApi + +on: + push: + branches: [ dev, master, main, release/* ] + paths: + - 'azure-functions-extension-fastapi/**' + pull_request: + branches: [ dev, master, main, release/* ] + paths: + - 'azure-functions-extension-fastapi/**' + +jobs: + build: + name: "Python Extension UT FastApi CI Run" + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: [ 3.8, 3.9, "3.10", "3.11" ] + permissions: read-all + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + working-directory: azure-functions-extension-fastapi + run: | + python -m pip install --upgrade pip + python -m pip install -U -e .[dev] + python -m pip install -e ../azure-functions-extension-base + + - name: Run Unit Tests + working-directory: azure-functions-extension-fastapi + env: + AzureWebJobsStorage: ${{ secrets.AzureWebJobsStorage }} + run: | + python -m pytest -q --instafail --cov=. --cov-report xml --cov-branch tests + + - name: Upload Coverage to Codecov + uses: codecov/codecov-action@v3 + with: + file: ./azure-functions-extension-fastapi/coverage.xml + flags: unittests + name: codecov + fail_ci_if_error: false + + - name: Notify dedicated teams channel + uses: jdcargile/ms-teams-notification@v1.4 + if: failure() + with: + github-token: ${{ github.token }} # this will use the runner's token. + ms-teams-webhook-uri: ${{ secrets.MS_TEAMS_WEBHOOK_URI }} + notification-summary: "Python Extension FastApi UT CI Failed for Python ${{ matrix.python-version }}" + notification-color: 17a2b8 + timezone: America/Denver + verbose-logging: false diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index 9fa123f..9f743d6 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -15,7 +15,18 @@ name: Lint Code Base ############################# # Start the job on all push # ############################# -on: [ push, pull_request, workflow_dispatch ] +on: + workflow_dispatch: + push: + branches: + - dev + - main + - 'release/*' + pull_request: + branches: + - dev + - main + - 'release/*' ############### # Set the Job # diff --git a/azure-functions-extension-base/README.md b/azure-functions-extension-base/README.md index 80d0c2d..0cfbeb1 100644 --- a/azure-functions-extension-base/README.md +++ b/azure-functions-extension-base/README.md @@ -1,38 +1,3 @@ # Azure Functions Extension Base library for Python -This is the base library for allowing Python Function Apps to recognize and bind to SDk-types. It is not to be used directly. -Instead, please reference one of the extending packages. - -Currently, the supported SDK-type bindings are: - -* Azure Storage Blob - -## Next steps - -### More sample code - -Get started with our [Blob samples](hhttps://github.com/Azure/azure-functions-python-extensions/tree/main/azure-functions-extension-blob/samples). - -Several samples are available in this GitHub repository. These samples provide example code for additional scenarios commonly encountered while working with Storage Blobs: - -* [blob_samples_blobclient](https://github.com/Azure/azure-functions-python-extensions/tree/main/azure-functions-extension-blob/samples/blob_samples_blobclient) - Examples for using the BlobClient type: - * From BlobTrigger - * From BlobInput - -* [blob_samples_containerclient](https://github.com/Azure/azure-functions-python-extensions/tree/main/azure-functions-extension-blob/samples/blob_samples_containerclient) - Examples for using the ContainerClient type: - * From BlobTrigger - * From BlobInput - -* [blob_samples_storagestreamdownloader](https://github.com/Azure/azure-functions-python-extensions/tree/main/azure-functions-extension-blob/samples/blob_samples_storagestreamdownloader) - Examples for using the StorageStreamDownloader type: - * From BlobTrigger - * From BlobInput - -### Additional documentation -For more information on the Azure Storage Blob SDK, see the [Azure Blob storage documentation](https://docs.microsoft.com/azure/storage/blobs/) on docs.microsoft.com -and the [Azure Storage Blobs README](https://github.com/Azure/azure-sdk-for-python/tree/main/sdk/storage/azure-storage-blob). - -## 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. \ No newline at end of file +This is the base library for allowing Python Function Apps to recognize and bind to SDk-types and HttpV2-types. It is not to be used directly. +Instead, please reference one of the extending packages. \ No newline at end of file diff --git a/azure-functions-extension-base/azure/functions/extension/base/__init__.py b/azure-functions-extension-base/azure/functions/extension/base/__init__.py index fa523e3..cf2ef38 100644 --- a/azure-functions-extension-base/azure/functions/extension/base/__init__.py +++ b/azure-functions-extension-base/azure/functions/extension/base/__init__.py @@ -1,10 +1,38 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from .meta import (Datum, _ConverterMeta, _BaseConverter, - InConverter, OutConverter, get_binding_registry) +from .meta import ( + Datum, + _ConverterMeta, + _BaseConverter, + InConverter, + OutConverter, + get_binding_registry +) from .sdkType import SdkType +from .web import ( + WebServer, + WebApp, + ModuleTrackerMeta, + RequestTrackerMeta, + ResponseTrackerMeta, + http_v2_enabled, + ResponseLabels +) -__all__ = ['Datum', '_ConverterMeta', '_BaseConverter', - 'InConverter', 'OutConverter', - 'SdkType', 'get_binding_registry'] +__all__ = [ + 'Datum', + '_ConverterMeta', + '_BaseConverter', + 'InConverter', + 'OutConverter', + 'SdkType', + 'get_binding_registry', + 'ModuleTrackerMeta', + 'RequestTrackerMeta', + 'ResponseTrackerMeta', + 'http_v2_enabled', + 'ResponseLabels', + 'WebServer', + 'WebApp' +] \ No newline at end of file diff --git a/azure-functions-extension-base/azure/functions/extension/base/sdkType.py b/azure-functions-extension-base/azure/functions/extension/base/sdkType.py index 53d9995..56e474c 100644 --- a/azure-functions-extension-base/azure/functions/extension/base/sdkType.py +++ b/azure-functions-extension-base/azure/functions/extension/base/sdkType.py @@ -2,11 +2,11 @@ # Licensed under the MIT License. from abc import abstractmethod -from typing import Any, Optional - +import sys +from typing import Any, Dict, Optional class SdkType: - def __init__(self, *, data: Optional[dict[str, Any]] = None): + def __init__(self, *, data: dict = None): self._data = data or {} @abstractmethod diff --git a/azure-functions-extension-base/azure/functions/extension/base/web.py b/azure-functions-extension-base/azure/functions/extension/base/web.py new file mode 100644 index 0000000..6ce87f2 --- /dev/null +++ b/azure-functions-extension-base/azure/functions/extension/base/web.py @@ -0,0 +1,127 @@ +from abc import abstractmethod +from enum import Enum +from typing import Callable + +base_extension_module = __name__ + + +# Base extension pkg +class ModuleTrackerMeta(type): + _module = None + + def __new__(cls, name, bases, dct, **kwargs): + new_class = super().__new__(cls, name, bases, dct) + new_module = dct.get('__module__') + if new_module != base_extension_module: + if cls._module is None: + cls._module = new_module + elif cls._module != new_module: + raise Exception(f'Only one web extension package shall be imported, ' + f'{cls._module} and {new_module} are imported') + return new_class + + @classmethod + def get_module(cls): + return cls._module + + @classmethod + def module_imported(cls): + return cls._module is not None + + +class RequestTrackerMeta(type): + _request_type = None + + def __new__(cls, name, bases, dct, **kwargs): + new_class = super().__new__(cls, name, bases, dct) + + request_type = dct.get('request_type') + + if request_type is None: + raise Exception(f'Request type not provided for class {name}') + + if cls._request_type is not None and cls._request_type != request_type: + raise Exception(f'Only one request type shall be recorded for class {name} ' + f'but found {cls._request_type} and {request_type}') + cls._request_type = request_type + + return new_class + + @classmethod + def get_request_type(cls): + return cls._request_type + + @classmethod + def check_type(cls, pytype: type) -> bool: + return cls._request_type is not None and issubclass(pytype, cls._request_type) + + +class ResponseTrackerMeta(type): + _response_types = {} + + def __new__(cls, name, bases, dct, **kwargs): + new_class = super().__new__(cls, name, bases, dct) + + label = dct.get('label') + response_type = dct.get('response_type') + + if label is None: + raise Exception(f'Response label not provided for class {name}') + if response_type is None: + raise Exception(f'Response type not provided for class {name}') + if cls._response_types.get(label) is not None and cls._response_types.get(label) != response_type: + raise Exception(f'Only one response type shall be recorded for class {name} ' + f'but found {cls._response_types.get(label)} and {response_type}') + + cls._response_types[label] = response_type + + return new_class + + @classmethod + def get_standard_response_type(cls): + return cls.get_response_type(ResponseLabels.STANDARD) + + @classmethod + def get_response_type(cls, label): + return cls._response_types.get(label) + + @classmethod + def check_type(cls, pytype: type) -> bool: + return cls._response_types is not None and any(issubclass(pytype, response_type) + for response_type in cls._response_types.values()) + +class WebApp(metaclass=ModuleTrackerMeta): + @abstractmethod + def route(self, func: Callable): + pass + + @abstractmethod + def get_app(self): + pass + + +class WebServer(metaclass=ModuleTrackerMeta): + def __init__(self, hostname, port, web_app: WebApp): + self.hostname = hostname + self.port = port + self.web_app = web_app.get_app() + + @abstractmethod + async def serve(self): + pass + + +def http_v2_enabled() -> bool: + return ModuleTrackerMeta.module_imported() + + +class ResponseLabels(Enum): + STANDARD = 'standard' + STREAMING = 'streaming' + FILE = 'file' + HTML = 'html' + JSON = 'json' + ORJSON = 'orjson' + PLAIN_TEXT = 'plain_text' + REDIRECT = 'redirect' + UJSON = 'ujson' diff --git a/azure-functions-extension-base/setup.py b/azure-functions-extension-base/setup.py index b24c8a7..cb1df9e 100644 --- a/azure-functions-extension-base/setup.py +++ b/azure-functions-extension-base/setup.py @@ -3,6 +3,19 @@ from setuptools import setup, find_packages +EXTRA_REQUIRES = { + 'dev': [ + 'flake8~=4.0.1', + 'flake8-logging-format', + 'mypy', + 'pytest', + 'pytest-cov', + 'requests==2.*', + 'coverage', + "pytest-instafail" + ] +} + setup( name='azure-functions-extension-base', version='1.0.0a1', @@ -13,9 +26,18 @@ 'azure.functions.extension', 'azure.functions', 'azure', 'tests' ]), classifiers=[ - 'Programming Language :: Python :: 3', 'License :: OSI Approved :: MIT License', - 'Operating System :: OS Independent', + 'Intended Audience :: Developers', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Operating System :: Microsoft :: Windows', + 'Operating System :: POSIX', + 'Operating System :: MacOS :: MacOS X', + 'Environment :: Web Environment', + 'Development Status :: 5 - Production/Stable', ], - python_requires='>=3.9' + license='MIT', + extras_require=EXTRA_REQUIRES, + python_requires='>=3.8' ) diff --git a/azure-functions-extension-base/tests/test_web.py b/azure-functions-extension-base/tests/test_web.py new file mode 100644 index 0000000..f886e72 --- /dev/null +++ b/azure-functions-extension-base/tests/test_web.py @@ -0,0 +1,216 @@ +import unittest +from unittest.mock import MagicMock, patch + +from azure.functions.extension.base import (ModuleTrackerMeta, RequestTrackerMeta, + ResponseTrackerMeta, WebApp, WebServer, + http_v2_enabled, ResponseLabels) + + +class TestModuleTrackerMeta(unittest.TestCase): + def setUp(self): + # Reset the _module attribute after each test + ModuleTrackerMeta._module = None + self.assertFalse(http_v2_enabled()) + + def test_classes_imported_from_same_module(self): + class TestClass1(metaclass=ModuleTrackerMeta): + pass + class TestClass2(metaclass=ModuleTrackerMeta): + pass + + self.assertEqual(ModuleTrackerMeta.get_module(), __name__) + self.assertTrue(ModuleTrackerMeta.module_imported()) + self.assertTrue(http_v2_enabled()) + + def test_class_imported_from_a_module(self): + class TestClass1(metaclass=ModuleTrackerMeta): + pass + self.assertEqual(ModuleTrackerMeta.get_module(), __name__) + self.assertTrue(ModuleTrackerMeta.module_imported()) + self.assertTrue(http_v2_enabled()) + + def test_classes_imported_from_different_modules(self): + class TestClass1(metaclass=ModuleTrackerMeta): + __module__ = "module1" + + self.assertEqual(ModuleTrackerMeta.get_module(), "module1") + self.assertTrue(ModuleTrackerMeta.module_imported()) + + with self.assertRaises(Exception) as context: + class TestClass2(metaclass=ModuleTrackerMeta): + __module__ = "module2" + + self.assertEqual(str(context.exception), + 'Only one web extension package shall be imported, ' + 'module1 and module2 are imported') + +class TestRequestTrackerMeta(unittest.TestCase): + class TestRequest1: + pass + + class TestRequest2: + pass + + class TestRequest3: + pass + + def setUp(self): + # Reset _request_type before each test + RequestTrackerMeta._request_type = None + + def test_request_type_not_provided(self): + # Define a class without providing the request_type attribute + with self.assertRaises(Exception) as context: + class TestClass(metaclass=RequestTrackerMeta): + pass + self.assertEqual(str(context.exception), 'Request type not provided for class TestClass') + + def test_single_request_type(self): + # Define a class providing a request_type attribute + class TestClass(metaclass=RequestTrackerMeta): + request_type = self.TestRequest1 + + # Ensure the request_type is correctly recorded + self.assertEqual(RequestTrackerMeta.get_request_type(), self.TestRequest1) + # Ensure check_type returns True for the provided request_type + self.assertTrue(RequestTrackerMeta.check_type(self.TestRequest1)) + + def test_multiple_request_types_same(self): + # Define a class providing the same request_type attribute + class TestClass1(metaclass=RequestTrackerMeta): + request_type = self.TestRequest1 + + # Ensure the request_type is correctly recorded + self.assertEqual(RequestTrackerMeta.get_request_type(), self.TestRequest1) + # Ensure check_type returns True for the provided request_type + self.assertTrue(RequestTrackerMeta.check_type(self.TestRequest1)) + + # Define another class providing the same request_type attribute + class TestClass2(metaclass=RequestTrackerMeta): + request_type = self.TestRequest1 + + # Ensure the request_type remains the same + self.assertEqual(RequestTrackerMeta.get_request_type(), self.TestRequest1) + # Ensure check_type still returns True for the original request_type + self.assertTrue(RequestTrackerMeta.check_type(self.TestRequest1)) + + def test_multiple_request_types_different(self): + # Define a class providing a different request_type attribute + class TestClass1(metaclass=RequestTrackerMeta): + request_type = self.TestRequest1 + + # Ensure the request_type is correctly recorded + self.assertEqual(RequestTrackerMeta.get_request_type(), self.TestRequest1) + # Ensure check_type returns True for the provided request_type + self.assertTrue(RequestTrackerMeta.check_type(self.TestRequest1)) + + # Define another class providing a different request_type attribute + with self.assertRaises(Exception) as context: + class TestClass2(metaclass=RequestTrackerMeta): + request_type = self.TestRequest2 + self.assertEqual(str(context.exception), + f'Only one request type shall be recorded for class TestClass2' + f' but found {self.TestRequest1} and {self.TestRequest2}') + + # Ensure the request_type remains the same after the exception + self.assertEqual(RequestTrackerMeta.get_request_type(), self.TestRequest1) + # Ensure check_type still returns True for the original request_type + self.assertTrue(RequestTrackerMeta.check_type(self.TestRequest1)) + +class TestResponseTrackerMeta(unittest.TestCase): + class MockResponse1: + pass + + class MockResponse2: + pass + + def test_classes_imported_from_same_module(self): + class TestResponse1(metaclass=ResponseTrackerMeta): + label = 'test_label_1' + response_type = self.MockResponse1 + + class TestResponse2(metaclass=ResponseTrackerMeta): + label = 'test_label_2' + response_type = self.MockResponse2 + + self.assertEqual(ResponseTrackerMeta.get_response_type('test_label_1'), self.MockResponse1) + self.assertEqual(ResponseTrackerMeta.get_response_type('test_label_2'), self.MockResponse2) + self.assertIsNone(ResponseTrackerMeta.get_response_type('non_existing_label')) + self.assertTrue(ResponseTrackerMeta.check_type(self.MockResponse1)) + self.assertTrue(ResponseTrackerMeta.check_type(self.MockResponse2)) + + def test_class_imported_from_a_module(self): + class TestResponse1(metaclass=ResponseTrackerMeta): + label = 'test_label_1' + response_type = self.MockResponse1 + + self.assertEqual(ResponseTrackerMeta.get_response_type('test_label_1'), self.MockResponse1) + self.assertIsNone(ResponseTrackerMeta.get_response_type('non_existing_label')) + self.assertTrue(ResponseTrackerMeta.check_type(self.MockResponse1)) + self.assertFalse(ResponseTrackerMeta.check_type(self.MockResponse2)) + + def test_classes_imported_from_different_modules(self): + class TestResponse1(metaclass=ResponseTrackerMeta): + __module__ = "module1" + label = 'test_label_1' + response_type = self.MockResponse1 + + with self.assertRaises(Exception) as context: + class TestResponse2(metaclass=ResponseTrackerMeta): + __module__ = "module2" + label = 'test_label_1' + response_type = self.MockResponse2 + + self.assertEqual(str(context.exception), + 'Only one response type shall be recorded for class TestResponse2 ' + f'but found {self.MockResponse1} and {self.MockResponse2}') + + def test_different_labels(self): + class TestResponse1(metaclass=ResponseTrackerMeta): + label = ResponseLabels.STANDARD + response_type = self.MockResponse1 + + class TestResponse2(metaclass=ResponseTrackerMeta): + label = ResponseLabels.STREAMING + response_type = self.MockResponse2 + + self.assertEqual(ResponseTrackerMeta.get_response_type(ResponseLabels.STANDARD), self.MockResponse1) + self.assertEqual(ResponseTrackerMeta.get_response_type(ResponseLabels.STREAMING), self.MockResponse2) + self.assertTrue(ResponseTrackerMeta.check_type(self.MockResponse1)) + self.assertTrue(ResponseTrackerMeta.check_type(self.MockResponse2)) + +class TestWebApp(unittest.TestCase): + def test_route_and_get_app(self): + class MockWebApp(WebApp): + def route(self, func): + return + + def get_app(self): + return "MockApp" + + app = MockWebApp() + self.assertEqual(app.get_app(), "MockApp") + +class TestWebServer(unittest.TestCase): + def test_web_server_initialization(self): + class MockWebApp(WebApp): + def route(self, func): + pass + + def get_app(self): + return "MockApp" + + mock_web_app = MockWebApp() + server = WebServer("localhost", 8080, mock_web_app) + self.assertEqual(server.hostname, "localhost") + self.assertEqual(server.port, 8080) + self.assertEqual(server.web_app, "MockApp") + +class TestHttpV2Enabled(unittest.TestCase): + @patch('azure.functions.extension.base.ModuleTrackerMeta.module_imported') + def test_http_v2_enabled(self, mock_module_imported): + mock_module_imported.return_value = True + self.assertTrue(http_v2_enabled()) + + mock_module_imported.return_value = False + self.assertFalse(http_v2_enabled()) \ No newline at end of file diff --git a/azure-functions-extension-blob/setup.py b/azure-functions-extension-blob/setup.py index 2393ce2..0d695c6 100644 --- a/azure-functions-extension-blob/setup.py +++ b/azure-functions-extension-blob/setup.py @@ -3,6 +3,21 @@ from setuptools import setup, find_packages +# TODO: pin to ext base version after published + +EXTRA_REQUIRES = { + 'dev': [ + 'flake8~=4.0.1', + 'flake8-logging-format', + 'mypy', + 'pytest', + 'pytest-cov', + 'requests==2.*', + 'coverage', + "pytest-instafail" + ] +} + setup( name='azure-functions-extension-blob', version='1.0.0a1', @@ -14,10 +29,18 @@ 'azure', 'tests', 'samples' ]), classifiers=[ - 'Programming Language :: Python :: 3', 'License :: OSI Approved :: MIT License', - 'Operating System :: OS Independent', + 'Intended Audience :: Developers', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.9', + 'Operating System :: Microsoft :: Windows', + 'Operating System :: POSIX', + 'Operating System :: MacOS :: MacOS X', + 'Environment :: Web Environment', + 'Development Status :: 5 - Production/Stable', ], + license='MIT', + extras_require=EXTRA_REQUIRES, python_requires='>=3.9', install_requires=[ 'azure-functions-extension-base', diff --git a/azure-functions-extension-blob/tests/test_blobclient.py b/azure-functions-extension-blob/tests/test_blobclient.py index d5d6818..8a83848 100644 --- a/azure-functions-extension-blob/tests/test_blobclient.py +++ b/azure-functions-extension-blob/tests/test_blobclient.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +import json import unittest from typing import Optional from enum import Enum @@ -88,13 +89,16 @@ def test_input_empty(self): self.assertIsNone(result) def test_input_populated(self): - # TODO: pass in variable connection string + content = { + "Connection": "AzureWebJobsStorage", + "ContainerName": "test-blob", + "BlobName": "text.txt" + } + sample_mbd = MockMBD(version="1.0", source="AzureStorageBlobs", content_type="application/json", - content="{\"Connection\":\"AzureWebJobsStorage\"," - "\"ContainerName\":\"test-blob\"," - "\"BlobName\":\"test.txt\"}") + content=json.dumps(content)) datum: Datum = Datum(value=sample_mbd, type='model_binding_data') result: BlobClient = BlobClientConverter.decode(data=datum, @@ -102,9 +106,9 @@ def test_input_populated(self): pytype=BlobClient) self.assertIsNotNone(result) - self.assertIsInstance(result, BlobClient) + self.assertIsInstance(result, BlobClientSdk) - sdk_result = BlobClient(data=datum).get_sdk_type() + sdk_result = BlobClient(data=datum.value).get_sdk_type() self.assertIsNotNone(sdk_result) self.assertIsInstance(sdk_result, BlobClientSdk) diff --git a/azure-functions-extension-blob/tests/test_containerclient.py b/azure-functions-extension-blob/tests/test_containerclient.py index 6dca311..1f2ef4a 100644 --- a/azure-functions-extension-blob/tests/test_containerclient.py +++ b/azure-functions-extension-blob/tests/test_containerclient.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +import json import unittest from typing import Optional from enum import Enum @@ -88,12 +89,15 @@ def test_input_empty(self): self.assertIsNone(result) def test_input_populated(self): + content = { + "Connection": "AzureWebJobsStorage", + "ContainerName": "test-blob", + "BlobName": "text.txt" + } sample_mbd = MockMBD(version="1.0", source="AzureStorageBlobs", content_type="application/json", - content="{\"Connection\":\"AzureWebJobsStorage\"," - "\"ContainerName\":\"test-blob\"," - "\"BlobName\":\"test.txt\"}") + content=json.dumps(content)) datum: Datum = Datum(value=sample_mbd, type='model_binding_data') result: ContainerClient = BlobClientConverter.decode( @@ -102,9 +106,9 @@ def test_input_populated(self): pytype=ContainerClient) self.assertIsNotNone(result) - self.assertIsInstance(result, ContainerClient) + self.assertIsInstance(result, ContainerClientSdk) - sdk_result = ContainerClient(data=datum).get_sdk_type() + sdk_result = ContainerClient(data=datum.value).get_sdk_type() self.assertIsNotNone(sdk_result) self.assertIsInstance(sdk_result, ContainerClientSdk) diff --git a/azure-functions-extension-blob/tests/test_ssd.py b/azure-functions-extension-blob/tests/test_ssd.py index 70f81a7..82d7599 100644 --- a/azure-functions-extension-blob/tests/test_ssd.py +++ b/azure-functions-extension-blob/tests/test_ssd.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +import json import unittest from typing import Optional from enum import Enum @@ -89,12 +90,15 @@ def test_input_empty(self): self.assertIsNone(result) def test_input_populated(self): - # TODO: pass in variable connection string + content = { + "Connection": "AzureWebJobsStorage", + "ContainerName": "test-blob", + "BlobName": "text.txt" + } + sample_mbd = MockMBD(version="1.0", source="AzureStorageBlobs", content_type="application/json", - content="{\"Connection\":\"AzureWebJobsStorage\"," - "\"ContainerName\":\"test-blob\"," - "\"BlobName\":\"test.txt\"}") + content=json.dumps(content)) datum: Datum = Datum(value=sample_mbd, type='model_binding_data') result: StorageStreamDownloader = ( @@ -103,9 +107,9 @@ def test_input_populated(self): pytype=StorageStreamDownloader)) self.assertIsNotNone(result) - self.assertIsInstance(result, StorageStreamDownloader) + self.assertIsInstance(result, SSDSdk) - sdk_result = StorageStreamDownloader(data=datum).get_sdk_type() + sdk_result = StorageStreamDownloader(data=datum.value).get_sdk_type() self.assertIsNotNone(sdk_result) self.assertIsInstance(sdk_result, SSDSdk) diff --git a/azure-functions-extension-fastapi/CHANGELOG.md b/azure-functions-extension-fastapi/CHANGELOG.md new file mode 100644 index 0000000..e69de29 diff --git a/azure-functions-extension-fastapi/LICENSE b/azure-functions-extension-fastapi/LICENSE new file mode 100644 index 0000000..63447fd --- /dev/null +++ b/azure-functions-extension-fastapi/LICENSE @@ -0,0 +1,21 @@ +Copyright (c) Microsoft Corporation. + +MIT License + +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. \ No newline at end of file diff --git a/azure-functions-extension-fastapi/MANIFEST.in b/azure-functions-extension-fastapi/MANIFEST.in new file mode 100644 index 0000000..e1ae5ad --- /dev/null +++ b/azure-functions-extension-fastapi/MANIFEST.in @@ -0,0 +1,3 @@ +recursive-include azure *.py *.pyi +recursive-include tests *.py +include LICENSE README.md \ No newline at end of file diff --git a/azure-functions-extension-fastapi/README.md b/azure-functions-extension-fastapi/README.md new file mode 100644 index 0000000..e69de29 diff --git a/azure-functions-extension-fastapi/azure/__init__.py b/azure-functions-extension-fastapi/azure/__init__.py new file mode 100644 index 0000000..8db66d3 --- /dev/null +++ b/azure-functions-extension-fastapi/azure/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/azure-functions-extension-fastapi/azure/functions/__init__.py b/azure-functions-extension-fastapi/azure/functions/__init__.py new file mode 100644 index 0000000..8db66d3 --- /dev/null +++ b/azure-functions-extension-fastapi/azure/functions/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/azure-functions-extension-fastapi/azure/functions/extension/__init__.py b/azure-functions-extension-fastapi/azure/functions/extension/__init__.py new file mode 100644 index 0000000..8db66d3 --- /dev/null +++ b/azure-functions-extension-fastapi/azure/functions/extension/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/azure-functions-extension-fastapi/azure/functions/extension/fastapi/__init__.py b/azure-functions-extension-fastapi/azure/functions/extension/fastapi/__init__.py new file mode 100644 index 0000000..3ac1a9e --- /dev/null +++ b/azure-functions-extension-fastapi/azure/functions/extension/fastapi/__init__.py @@ -0,0 +1,16 @@ +from .web import WebServer, WebApp +from fastapi import Request, Response +from fastapi.responses import ( + StreamingResponse, + HTMLResponse, + PlainTextResponse, + RedirectResponse, + JSONResponse, + UJSONResponse, + ORJSONResponse, + FileResponse, +) + +__all__ = ['WebServer', 'WebApp', 'Request', 'Response', 'StreamingResponse', 'HTMLResponse', + 'PlainTextResponse', 'RedirectResponse', 'JSONResponse', 'UJSONResponse', + 'ORJSONResponse', 'FileResponse'] \ No newline at end of file diff --git a/azure-functions-extension-fastapi/azure/functions/extension/fastapi/web.py b/azure-functions-extension-fastapi/azure/functions/extension/fastapi/web.py new file mode 100644 index 0000000..bff0812 --- /dev/null +++ b/azure-functions-extension-fastapi/azure/functions/extension/fastapi/web.py @@ -0,0 +1,82 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Callable +from fastapi import Request as FastApiRequest, Response as FastApiResponse, FastAPI +from fastapi.responses import ( + StreamingResponse as FastApiStreamingResponse, + HTMLResponse as FastApiHTMLResponse, + PlainTextResponse as FastApiPlainTextResponse, + RedirectResponse as FastApiRedirectResponse, + JSONResponse as FastApiJSONResponse, + UJSONResponse as FastApiUJSONResponse, + ORJSONResponse as FastApiORJSONResponse, + FileResponse as FastApiFileResponse, +) +from azure.functions.extension.base import WebServer, WebApp, RequestTrackerMeta, ResponseTrackerMeta, ResponseLabels +import uvicorn + +class Request(metaclass=RequestTrackerMeta): + request_type = FastApiRequest + +class Response(metaclass=ResponseTrackerMeta): + label = ResponseLabels.STANDARD + response_type = FastApiResponse + +class StreamingResponse(metaclass=ResponseTrackerMeta): + label = ResponseLabels.STREAMING + response_type = FastApiStreamingResponse + +class HTMLResponse(metaclass=ResponseTrackerMeta): + label = ResponseLabels.HTML + response_type = FastApiHTMLResponse + +class PlainTextResponse(metaclass=ResponseTrackerMeta): + label = ResponseLabels.PLAIN_TEXT + response_type = FastApiPlainTextResponse + +class RedirectResponse(metaclass=ResponseTrackerMeta): + label = ResponseLabels.REDIRECT + response_type = FastApiRedirectResponse + +class JSONResponse(metaclass=ResponseTrackerMeta): + label = ResponseLabels.JSON + response_type = FastApiJSONResponse + +class UJSONResponse(metaclass=ResponseTrackerMeta): + label = ResponseLabels.UJSON + response_type = FastApiUJSONResponse + +class ORJSONResponse(metaclass=ResponseTrackerMeta): + label = ResponseLabels.ORJSON + response_type = FastApiORJSONResponse + +class FileResponse(metaclass=ResponseTrackerMeta): + label = ResponseLabels.FILE + response_type = FastApiFileResponse + + +class WebApp(WebApp): + def __init__(self): + self.web_app = FastAPI() + + def route(self, func: Callable): + # Apply the api_route decorator + decorated_function = self.web_app.api_route( + "/{path:path}", + methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH", "TRACE"] + )(func) + + return decorated_function + + def get_app(self): + return self.web_app + + +class WebServer(WebServer): + async def serve(self): + uvicorn_config = uvicorn.Config(self.web_app, host=self.hostname, port=self.port) + + server = uvicorn.Server(uvicorn_config) + + return await server.serve() \ No newline at end of file diff --git a/azure-functions-extension-fastapi/samples/fastapi_samples_streaming_download/function_app.py b/azure-functions-extension-fastapi/samples/fastapi_samples_streaming_download/function_app.py new file mode 100644 index 0000000..48eda6e --- /dev/null +++ b/azure-functions-extension-fastapi/samples/fastapi_samples_streaming_download/function_app.py @@ -0,0 +1,23 @@ +# This Azure Function streams real-time sensor data using Server-Sent Events (SSE). +# It simulates a sensor network transmitting temperature and humidity readings, +# which can be consumed by IoT dashboards or analytics pipelines. + +import time +import azure.functions as func +from azure.functions.extension.fastapi import Request, StreamingResponse + +app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS) + +def generate_sensor_data(): + """Generate real-time sensor data.""" + for i in range(10): + # Simulate temperature and humidity readings + temperature = 20 + i + humidity = 50 + i + yield f"data: {{'temperature': {temperature}, 'humidity': {humidity}}}\n\n" + time.sleep(1) + +@app.route(route="stream", methods=[func.HttpMethod.GET]) +async def stream_sensor_data(req: Request) -> StreamingResponse: + """Endpoint to stream real-time sensor data.""" + return StreamingResponse(generate_sensor_data(), media_type='text/event-stream') diff --git a/azure-functions-extension-fastapi/samples/fastapi_samples_streaming_download/host.json b/azure-functions-extension-fastapi/samples/fastapi_samples_streaming_download/host.json new file mode 100644 index 0000000..9df9136 --- /dev/null +++ b/azure-functions-extension-fastapi/samples/fastapi_samples_streaming_download/host.json @@ -0,0 +1,15 @@ +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + } + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} \ No newline at end of file diff --git a/azure-functions-extension-fastapi/samples/fastapi_samples_streaming_download/local.settings.json b/azure-functions-extension-fastapi/samples/fastapi_samples_streaming_download/local.settings.json new file mode 100644 index 0000000..c3c2a89 --- /dev/null +++ b/azure-functions-extension-fastapi/samples/fastapi_samples_streaming_download/local.settings.json @@ -0,0 +1,8 @@ +{ + "IsEncrypted": false, + "Values": { + "FUNCTIONS_WORKER_RUNTIME": "python", + "AzureWebJobsStorage": "", + "AzureWebJobsFeatureFlags": "EnableWorkerIndexing" + } +} \ No newline at end of file diff --git a/azure-functions-extension-fastapi/samples/fastapi_samples_streaming_download/requirements.txt b/azure-functions-extension-fastapi/samples/fastapi_samples_streaming_download/requirements.txt new file mode 100644 index 0000000..c871799 --- /dev/null +++ b/azure-functions-extension-fastapi/samples/fastapi_samples_streaming_download/requirements.txt @@ -0,0 +1,6 @@ +# DO NOT include azure-functions-worker in this file +# The Python Worker is managed by Azure Functions platform +# Manually managing azure-functions-worker may cause unexpected issues + +azure-functions +azure-functions-extension-fastapi \ No newline at end of file diff --git a/azure-functions-extension-fastapi/samples/fastapi_samples_streaming_upload/function_app.py b/azure-functions-extension-fastapi/samples/fastapi_samples_streaming_upload/function_app.py new file mode 100644 index 0000000..f03c135 --- /dev/null +++ b/azure-functions-extension-fastapi/samples/fastapi_samples_streaming_upload/function_app.py @@ -0,0 +1,23 @@ +# This Azure Function receives streaming data from a client and processes it in real-time. +# It demonstrates streaming upload capabilities for scenarios such as uploading large files, +# processing continuous data streams, or handling IoT device data. + +import azure.functions as func +from azure.functions.extension.fastapi import Request, JSONResponse + +app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS) + +@app.route(route="streaming_upload", methods=[func.HttpMethod.POST]) +async def streaming_upload(req: Request) -> JSONResponse: + """Handle streaming upload requests.""" + # Process each chunk of data as it arrives + async for chunk in req.stream(): + process_data_chunk(chunk) + + # Once all data is received, return a JSON response indicating successful processing + return JSONResponse({"status": "Data uploaded and processed successfully"}) + +def process_data_chunk(chunk: bytes): + """Process each data chunk.""" + # Add custom processing logic here + pass diff --git a/azure-functions-extension-fastapi/samples/fastapi_samples_streaming_upload/host.json b/azure-functions-extension-fastapi/samples/fastapi_samples_streaming_upload/host.json new file mode 100644 index 0000000..9df9136 --- /dev/null +++ b/azure-functions-extension-fastapi/samples/fastapi_samples_streaming_upload/host.json @@ -0,0 +1,15 @@ +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + } + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} \ No newline at end of file diff --git a/azure-functions-extension-fastapi/samples/fastapi_samples_streaming_upload/local.settings.json b/azure-functions-extension-fastapi/samples/fastapi_samples_streaming_upload/local.settings.json new file mode 100644 index 0000000..c3c2a89 --- /dev/null +++ b/azure-functions-extension-fastapi/samples/fastapi_samples_streaming_upload/local.settings.json @@ -0,0 +1,8 @@ +{ + "IsEncrypted": false, + "Values": { + "FUNCTIONS_WORKER_RUNTIME": "python", + "AzureWebJobsStorage": "", + "AzureWebJobsFeatureFlags": "EnableWorkerIndexing" + } +} \ No newline at end of file diff --git a/azure-functions-extension-fastapi/samples/fastapi_samples_streaming_upload/requirements.txt b/azure-functions-extension-fastapi/samples/fastapi_samples_streaming_upload/requirements.txt new file mode 100644 index 0000000..c871799 --- /dev/null +++ b/azure-functions-extension-fastapi/samples/fastapi_samples_streaming_upload/requirements.txt @@ -0,0 +1,6 @@ +# DO NOT include azure-functions-worker in this file +# The Python Worker is managed by Azure Functions platform +# Manually managing azure-functions-worker may cause unexpected issues + +azure-functions +azure-functions-extension-fastapi \ No newline at end of file diff --git a/azure-functions-extension-fastapi/setup.py b/azure-functions-extension-fastapi/setup.py new file mode 100644 index 0000000..d92b7d8 --- /dev/null +++ b/azure-functions-extension-fastapi/setup.py @@ -0,0 +1,51 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from setuptools import setup, find_packages + +# TODO: pin to ext base version after published + +EXTRA_REQUIRES = { + 'dev': [ + 'flake8~=4.0.1', + 'flake8-logging-format', + 'mypy', + 'pytest', + 'pytest-cov', + 'requests==2.*', + 'coverage', + 'pytest-instafail' + ] +} + +setup( + name='azure-functions-extension-fastapi', + version='0.0.1', + author='Azure Functions team at Microsoft Corp.', + author_email='azurefunctions@microsoft.com', + description='FastApi Python worker extension for Azure Functions.', + packages=find_packages(exclude=[ + 'azure.functions.extension', 'azure.functions', + 'azure', 'tests', 'samples' + ]), + classifiers=[ + 'License :: OSI Approved :: MIT License', + 'Intended Audience :: Developers', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Operating System :: Microsoft :: Windows', + 'Operating System :: POSIX', + 'Operating System :: MacOS :: MacOS X', + 'Environment :: Web Environment', + 'Development Status :: 5 - Production/Stable', + ], + license='MIT', + python_requires='>=3.8', + install_requires=[ + 'azure-functions-extension-base', + 'fastapi==0.110.0', + 'uvicorn==0.28.0' + ], + extras_require=EXTRA_REQUIRES +) diff --git a/azure-functions-extension-fastapi/tests/__init__.py b/azure-functions-extension-fastapi/tests/__init__.py new file mode 100644 index 0000000..d138d18 --- /dev/null +++ b/azure-functions-extension-fastapi/tests/__init__.py @@ -0,0 +1,21 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Bootstrap for '$ python setup.py test' command.""" + +import os.path +import sys +import unittest +import unittest.runner + + +def suite(): + test_loader = unittest.TestLoader() + return test_loader.discover( + os.path.dirname(__file__), pattern='test_*.py') + + +if __name__ == '__main__': + runner = unittest.runner.TextTestRunner() + result = runner.run(suite()) + sys.exit(not result.wasSuccessful()) diff --git a/azure-functions-extension-fastapi/tests/test_code_quality.py b/azure-functions-extension-fastapi/tests/test_code_quality.py new file mode 100644 index 0000000..0e8ebfd --- /dev/null +++ b/azure-functions-extension-fastapi/tests/test_code_quality.py @@ -0,0 +1,35 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import pathlib +import subprocess +import sys +import unittest + + +ROOT_PATH = pathlib.Path(__file__).parent.parent + + +class TestCodeQuality(unittest.TestCase): + + def test_flake8(self): + try: + import flake8 # NoQA + except ImportError: + raise unittest.SkipTest('flake8 module is missing') + + config_path = ROOT_PATH / '.flake8' + if not config_path.exists(): + raise unittest.SkipTest('could not locate the .flake8 file') + + try: + subprocess.run( + [sys.executable, '-m', 'flake8', '--config', str(config_path)], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=str(ROOT_PATH)) + except subprocess.CalledProcessError as ex: + output = ex.output.decode() + raise AssertionError( + f'flake8 validation failed:\n{output}') from None diff --git a/azure-functions-extension-fastapi/tests/test_web.py b/azure-functions-extension-fastapi/tests/test_web.py new file mode 100644 index 0000000..2468a82 --- /dev/null +++ b/azure-functions-extension-fastapi/tests/test_web.py @@ -0,0 +1,104 @@ +import asyncio +import unittest +from unittest.mock import MagicMock, patch +from fastapi import FastAPI +from azure.functions.extension.base import ResponseLabels, RequestTrackerMeta, ResponseTrackerMeta +from azure.functions.extension.fastapi import ( + WebApp, + WebServer, + Request as FastApiRequest, + Response as FastApiResponse, + JSONResponse, +) + +class TestRequestTrackerMeta(unittest.TestCase): + def test_request_type_defined(self): + class Request(metaclass=RequestTrackerMeta): + request_type = FastApiRequest + + self.assertTrue(hasattr(Request, 'request_type')) + self.assertEqual(Request.request_type, FastApiRequest) + + def test_request_type_undefined(self): + with self.assertRaises(Exception) as context: + class Request(metaclass=RequestTrackerMeta): + pass + + self.assertTrue('Request type not provided' in str(context.exception)) + + +class TestResponseTrackerMeta(unittest.TestCase): + def test_response_labels_defined(self): + class Response(metaclass=ResponseTrackerMeta): + label = ResponseLabels.STANDARD + response_type = FastApiResponse + + self.assertTrue(hasattr(Response, 'label')) + self.assertTrue(hasattr(Response, 'response_type')) + self.assertEqual(Response.label, ResponseLabels.STANDARD) + self.assertEqual(Response.response_type, FastApiResponse) + + def test_response_labels_undefined(self): + with self.assertRaises(Exception) as context: + class Response(metaclass=ResponseTrackerMeta): + pass + + self.assertTrue('Response label not provided' in str(context.exception)) + + def test_multiple_response_labels(self): + with self.assertRaises(Exception) as context: + class Response1(metaclass=ResponseTrackerMeta): + label = ResponseLabels.STANDARD + response_type = FastApiResponse + + class Response2(metaclass=ResponseTrackerMeta): + label = ResponseLabels.STANDARD + response_type = JSONResponse + + self.assertTrue('Only one response type shall be recorded' in str(context.exception)) + + +class TestWebApp(unittest.TestCase): + def setUp(self): + self.web_app = WebApp() + + def test_route(self): + @self.web_app.route + def test_route(request: FastApiRequest): + return {'message': 'Hello'} + + self.assertTrue('/{path:path}' in [endpoint.path for endpoint in self.web_app.web_app.router.routes]) + route = [endpoint for endpoint in self.web_app.web_app.router.routes if endpoint.path == '/{path:path}'][0] + self.assertEqual(route.methods, {"GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH", "TRACE"}) + + + def test_get_app(self): + self.assertIsInstance(self.web_app.get_app(), FastAPI) + +class TestWebServer(unittest.TestCase): + def setUp(self): + class TestApp(): + pass + self.test_app = TestApp() + self.web_app_mock = MagicMock().get_app() + self.web_app_mock.get_app.return_value = self.test_app + self.hostname = 'localhost' + self.port = 8000 + self.web_server = WebServer(self.hostname, self.port, self.web_app_mock) + + @patch('uvicorn.Config') + @patch('uvicorn.Server') + def test_serve(self, server_mock, config_mock): + async def serve(): + await asyncio.sleep(0) + config_instance_mock = config_mock.return_value + server_instance_mock = server_mock.return_value + server_instance_mock.serve = serve # Mock the serve method to return a CoroutineMock + + asyncio.get_event_loop().run_until_complete(self.web_server.serve()) + + config_mock.assert_called_once_with(self.test_app, host=self.hostname, port=self.port) + server_mock.assert_called_once_with(config_instance_mock) + + async def run_serve(self): + await self.web_server.serve() \ No newline at end of file