From ea8801251da215bac198b04489e080cf67e70c25 Mon Sep 17 00:00:00 2001 From: peterstone2017 Date: Mon, 7 Mar 2022 18:04:10 -0600 Subject: [PATCH 1/6] add tests for asgi/wsgi --- .flake8 | 2 +- setup.py | 3 + .../test_third_party_http_functions.py | 161 ++++++ .../asgi_function/function_app.py | 40 ++ .../wsgi_function/function_app.py | 36 ++ tests/stein_tests/testutils.py | 471 ++++++++++++++++++ .../test_third_party_http_functions.py | 221 ++++++++ .../asgi_function/function_app.py | 175 +++++++ .../wsgi_function/function_app.py | 103 ++++ 9 files changed, 1211 insertions(+), 1 deletion(-) create mode 100644 tests/stein_tests/endtoendtests/test_third_party_http_functions.py create mode 100644 tests/stein_tests/endtoendtests/third_party_http_functions/asgi_function/function_app.py create mode 100644 tests/stein_tests/endtoendtests/third_party_http_functions/wsgi_function/function_app.py create mode 100644 tests/stein_tests/testutils.py create mode 100644 tests/stein_tests/unittests/test_third_party_http_functions.py create mode 100644 tests/stein_tests/unittests/third_party_http_functions/asgi_function/function_app.py create mode 100644 tests/stein_tests/unittests/third_party_http_functions/wsgi_function/function_app.py diff --git a/.flake8 b/.flake8 index caba1c751..ba9246785 100644 --- a/.flake8 +++ b/.flake8 @@ -10,6 +10,6 @@ exclude = .git, __pycache__, build, dist, .eggs, .github, .local, docs/, azure_functions_worker/_thirdparty/typing_inspect.py, tests/unittests/test_typing_inspect.py, tests/unittests/broken_functions/syntax_error/main.py, - .env*, .vscode, venv*, *.venv*, + .env*, .vscode, venv*, *.venv*, worker_env max-line-length = 80 diff --git a/setup.py b/setup.py index 95484f0ef..0554e9852 100644 --- a/setup.py +++ b/setup.py @@ -112,6 +112,9 @@ "dev": [ "azure-eventhub~=5.7.0", # Used for EventHub E2E tests "python-dateutil~=2.8.2", + "flask", + "fastapi", + "pydantic", "pycryptodome~=3.10.1", "flake8~=4.0.1", "mypy", diff --git a/tests/stein_tests/endtoendtests/test_third_party_http_functions.py b/tests/stein_tests/endtoendtests/test_third_party_http_functions.py new file mode 100644 index 000000000..e3691f170 --- /dev/null +++ b/tests/stein_tests/endtoendtests/test_third_party_http_functions.py @@ -0,0 +1,161 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import os +from unittest.mock import patch + +import requests + +from azure_functions_worker import testutils as utils +from tests.stein_tests import testutils +from tests.stein_tests.constants import E2E_TESTS_ROOT + +HOST_JSON_TEMPLATE = """\ +{ + "version": "2.0", + "logging": { + "logLevel": { + "default": "Trace" + } + }, + "extensions": { + "http": { + "routePrefix": "" + } + }, + "functionTimeout": "00:05:00" +} +""" + + +class ThirdPartyHttpFunctionsTestBase: + class TestThirdPartyHttpFunctions(testutils.WebHostTestCase): + @classmethod + def setUpClass(cls): + host_json = cls.get_script_dir() / 'host.json' + with open(host_json, 'w+') as f: + f.write(HOST_JSON_TEMPLATE) + os_environ = os.environ.copy() + # Turn on feature flag + os_environ['AzureWebJobsFeatureFlags'] = 'EnableWorkerIndexing' + cls._patch_environ = patch.dict('os.environ', os_environ) + cls._patch_environ.start() + super().setUpClass() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + cls._patch_environ.stop() + + @classmethod + def get_script_dir(cls): + pass + + @utils.retryable_test(3, 5) + def test_function_index_page_should_return_undefined(self): + root_url = self.webhost._addr + r = requests.get(root_url) + self.assertEqual(r.status_code, 404) + + @utils.retryable_test(3, 5) + def test_get_endpoint_should_return_ok(self): + """Test if the default template of Http trigger in Python + Function app + will return OK + """ + r = self.webhost.request('GET', 'get_query_param', no_prefix=True) + self.assertTrue(r.ok) + self.assertEqual(r.text, "hello world") + + @utils.retryable_test(3, 5) + def test_get_endpoint_should_accept_query_param(self): + """Test if the azure.functions SDK is able to deserialize query + parameter from the default template + """ + r = self.webhost.request('GET', 'get_query_param', + params={'name': 'dummy'}, no_prefix=True) + self.assertTrue(r.ok) + self.assertEqual( + r.text, + "hello dummy" + ) + + @utils.retryable_test(3, 5) + def test_post_endpoint_should_accept_body(self): + """Test if the azure.functions SDK is able to deserialize http body + and pass it to default template + """ + r = self.webhost.request('POST', 'post_str', + data="dummy", + headers={'content-type': 'text/plain'}, + no_prefix=True) + self.assertTrue(r.ok) + self.assertEqual( + r.text, + "hello dummy" + ) + + @utils.retryable_test(3, 5) + def test_worker_status_endpoint_should_return_ok(self): + """Test if the worker status endpoint will trigger + _handle__worker_status_request and sends a worker status + response back + to host + """ + root_url = self.webhost._addr + health_check_url = f'{root_url}/admin/host/ping' + r = requests.post(health_check_url, + params={'checkHealth': '1'}) + self.assertTrue(r.ok) + + @utils.retryable_test(3, 5) + def test_worker_status_endpoint_should_return_ok_when_disabled(self): + """Test if the worker status endpoint will trigger + _handle__worker_status_request and sends a worker status + response back + to host + """ + os.environ['WEBSITE_PING_METRICS_SCALE_ENABLED'] = '0' + root_url = self.webhost._addr + health_check_url = f'{root_url}/admin/host/ping' + r = requests.post(health_check_url, + params={'checkHealth': '1'}) + self.assertTrue(r.ok) + + @utils.retryable_test(3, 5) + def test_get_endpoint_should_accept_path_param(self): + r = self.webhost.request('GET', 'get_path_param/1', no_prefix=True) + self.assertTrue(r.ok) + self.assertEqual(r.text, "hello 1") + + @utils.retryable_test(3, 5) + def test_post_json_body_and_return_json_response(self): + test_data = { + "name": "apple", + "description": "yummy" + } + r = self.webhost.request('POST', 'post_json_return_json_response', + json=test_data, + no_prefix=True) + self.assertTrue(r.ok) + self.assertEqual(r.json(), test_data) + + @utils.retryable_test(3, 5) + def test_raise_exception_should_return_not_found(self): + r = self.webhost.request('GET', 'raise_http_exception', + no_prefix=True) + self.assertEqual(r.status_code, 404) + self.assertEqual(r.json(), {"detail": "Item not found"}) + + +class TestAsgiHttpFunctions( + ThirdPartyHttpFunctionsTestBase.TestThirdPartyHttpFunctions): + @classmethod + def get_script_dir(cls): + return E2E_TESTS_ROOT / 'third_party_http_functions' / 'asgi_function' + + +class TestWsgiHttpFunctions( + ThirdPartyHttpFunctionsTestBase.TestThirdPartyHttpFunctions): + @classmethod + def get_script_dir(cls): + return E2E_TESTS_ROOT / 'third_party_http_functions' / 'wsgi_function' diff --git a/tests/stein_tests/endtoendtests/third_party_http_functions/asgi_function/function_app.py b/tests/stein_tests/endtoendtests/third_party_http_functions/asgi_function/function_app.py new file mode 100644 index 000000000..bf55de7e9 --- /dev/null +++ b/tests/stein_tests/endtoendtests/third_party_http_functions/asgi_function/function_app.py @@ -0,0 +1,40 @@ +from typing import Optional + +import azure.functions as func +from fastapi import FastAPI, Response, Body, HTTPException +from pydantic import BaseModel + +fast_app = FastAPI() + + +class Fruit(BaseModel): + name: str + description: Optional[str] = None + + +@fast_app.get("/get_query_param") +async def get_query_param(name: str = "world"): + return Response(content=f"hello {name}", media_type="text/plain") + + +@fast_app.post("/post_str") +async def post_str(person: str = Body(...)): + return Response(content=f"hello {person}", media_type="text/plain") + + +@fast_app.post("/post_json_return_json_response") +async def post_json_return_json_response(fruit: Fruit): + return fruit + + +@fast_app.get("/get_path_param/{id}") +async def get_path_param(id): + return Response(content=f"hello {id}", media_type="text/plain") + + +@fast_app.get("/raise_http_exception") +async def raise_http_exception(): + raise HTTPException(status_code=404, detail="Item not found") + + +app = func.FunctionApp(asgi_app=fast_app, auth_level=func.AuthLevel.ANONYMOUS) diff --git a/tests/stein_tests/endtoendtests/third_party_http_functions/wsgi_function/function_app.py b/tests/stein_tests/endtoendtests/third_party_http_functions/wsgi_function/function_app.py new file mode 100644 index 000000000..3c8e98cec --- /dev/null +++ b/tests/stein_tests/endtoendtests/third_party_http_functions/wsgi_function/function_app.py @@ -0,0 +1,36 @@ +import azure.functions as func +from flask import Flask, request + +flask_app = Flask(__name__) + + +@flask_app.get("/get_query_param") +def get_query_param(): + name = request.args.get("name") + if name is None: + name = "world" + return f"hello {name}" + + +@flask_app.post("/post_str") +def post_str(): + return f"hello {request.data.decode()}" + + +@flask_app.post("/post_json_return_json_response") +def post_json_return_json_response(): + return request.get_json() + + +@flask_app.get("/get_path_param/") +def get_path_param(id): + return f"hello {id}" + + +@flask_app.get("/raise_http_exception") +def raise_http_exception(): + return {"detail": "Item not found"}, 404 + + +app = func.FunctionApp(wsgi_app=flask_app.wsgi_app, + auth_level=func.AuthLevel.ANONYMOUS) diff --git a/tests/stein_tests/testutils.py b/tests/stein_tests/testutils.py new file mode 100644 index 000000000..2c98fd682 --- /dev/null +++ b/tests/stein_tests/testutils.py @@ -0,0 +1,471 @@ +import argparse +import configparser +import functools +import logging +import os +import pathlib +import platform +import re +import shutil +import socket +import subprocess +import sys +import tempfile +import time +import unittest + +import requests +from tests.stein_tests.constants import PYAZURE_WEBHOST_DEBUG, \ + PYAZURE_WORKER_DIR, \ + PYAZURE_INTEGRATION_TEST, PROJECT_ROOT, STEIN_TESTS_ROOT, WORKER_CONFIG, \ + LOCALHOST, WORKER_PATH, HTTP_FUNCS_PATH + +EXTENSIONS_PATH = PROJECT_ROOT / 'build' / 'extensions' / 'bin' +WEBHOST_DLL = "Microsoft.Azure.WebJobs.Script.WebHost.dll" +DEFAULT_WEBHOST_DLL_PATH = ( + PROJECT_ROOT / 'build' / 'webhost' / 'bin' / WEBHOST_DLL +) + + +# The template of host.json that will be applied to each test functions +HOST_JSON_TEMPLATE = """\ +{ + "version": "2.0", + "logging": {"logLevel": {"default": "Trace"}} +} +""" + +EXTENSION_CSPROJ_TEMPLATE = """\ + + + netcoreapp3.1 + + ** + + + + + + + + + + + +""" + +SECRETS_TEMPLATE = """\ +{ + "masterKey": { + "name": "master", + "value": "testMasterKey", + "encrypted": false + }, + "functionKeys": [ + { + "name": "default", + "value": "testFunctionKey", + "encrypted": false + } + ], + "systemKeys": [ + { + "name": "eventgridextensionconfig_extension", + "value": "testSystemKey", + "encrypted": false + } + ], + "hostName": null, + "instanceId": "0000000000000000000000001C69C103", + "source": "runtime" +} +""" + + +def is_env_var_true(env_key: str) -> bool: + if os.getenv(env_key) is None: + return False + + return is_true_like(os.environ[env_key]) + + +def is_true_like(setting: str) -> bool: + if setting is None: + return False + + return setting.lower().strip() in ['1', 'true', 't', 'yes', 'y'] + + +def remove_path(path): + if path.is_symlink(): + path.unlink() + elif path.is_dir(): + shutil.rmtree(str(path)) + elif path.exists(): + path.unlink() + + +def _symlink_dir(src, dst): + remove_path(dst) + + if platform.system() == 'Windows': + shutil.copytree(str(src), str(dst)) + else: + dst.symlink_to(src, target_is_directory=True) + + +class _WebHostProxy: + + def __init__(self, proc, addr): + self._proc = proc + self._addr = addr + + def request(self, meth, funcname, *args, **kwargs): + request_method = getattr(requests, meth.lower()) + params = dict(kwargs.pop('params', {})) + no_prefix = kwargs.pop('no_prefix', False) + if 'code' not in params: + params['code'] = 'testFunctionKey' + + return request_method( + self._addr + ('/' if no_prefix else '/api/') + funcname, + *args, params=params, **kwargs) + + def close(self): + if self._proc.stdout: + self._proc.stdout.close() + if self._proc.stderr: + self._proc.stderr.close() + + self._proc.terminate() + try: + self._proc.wait(20) + except subprocess.TimeoutExpired: + self._proc.kill() + + +def _find_open_port(): + with socket.socket() as s: + s.bind((LOCALHOST, 0)) + s.listen(1) + return s.getsockname()[1] + + +def popen_webhost(*, stdout, stderr, script_root=HTTP_FUNCS_PATH, port=None): + testconfig = None + if WORKER_CONFIG.exists(): + testconfig = configparser.ConfigParser() + testconfig.read(WORKER_CONFIG) + + hostexe_args = [] + + os.environ['AzureWebJobsFeatureFlags'] = 'EnableWorkerIndexing' + + # If we want to use core-tools + coretools_exe = os.environ.get('CORE_TOOLS_EXE_PATH') + if coretools_exe: + coretools_exe = coretools_exe.strip() + if pathlib.Path(coretools_exe).exists(): + hostexe_args = [str(coretools_exe), 'host', 'start'] + if port is not None: + hostexe_args.extend(['--port', str(port)]) + + # If we need to use Functions host directly + if not hostexe_args: + dll = os.environ.get('PYAZURE_WEBHOST_DLL') + if not dll and testconfig and testconfig.has_section('webhost'): + dll = testconfig['webhost'].get('dll') + + if dll: + # Paths from environment might contain trailing + # or leading whitespace. + dll = dll.strip() + + if not dll: + dll = DEFAULT_WEBHOST_DLL_PATH + + os.makedirs(dll.parent / 'Secrets', exist_ok=True) + with open(dll.parent / 'Secrets' / 'host.json', 'w') as f: + secrets = SECRETS_TEMPLATE + + f.write(secrets) + + if dll and pathlib.Path(dll).exists(): + hostexe_args = ['dotnet', str(dll)] + + if not hostexe_args: + raise RuntimeError('\n'.join([ + 'Unable to locate Azure Functions Host binary.', + 'Please do one of the following:', + ' * run the following command from the root folder of', + ' the project:', + '', + f' $ {sys.executable} setup.py webhost', + '', + ' * or download or build the Azure Functions Host and' + ' then write the full path to WebHost.dll' + ' into the `PYAZURE_WEBHOST_DLL` environment variable.', + ' Alternatively, you can create the', + f' {WORKER_CONFIG.name} file in the root folder', + ' of the project with the following structure:', + '', + ' [webhost]', + ' dll = /path/Microsoft.Azure.WebJobs.Script.WebHost.dll', + ' * or download Azure Functions Core Tools binaries and', + ' then write the full path to func.exe into the ', + ' `CORE_TOOLS_EXE_PATH` envrionment variable.', + '', + 'Setting "export PYAZURE_WEBHOST_DEBUG=true" to get the full', + 'stdout and stderr from function host.' + ])) + + worker_path = os.environ.get(PYAZURE_WORKER_DIR) + worker_path = WORKER_PATH if not worker_path else pathlib.Path(worker_path) + if not worker_path.exists(): + raise RuntimeError(f'Worker path {worker_path} does not exist') + + # Casting to strings is necessary because Popen doesn't like + # path objects there on Windows. + extra_env = { + 'AzureWebJobsScriptRoot': str(script_root), + 'languageWorkers:python:workerDirectory': str(worker_path), + 'host:logger:consoleLoggingMode': 'always', + 'AZURE_FUNCTIONS_ENVIRONMENT': 'development', + 'AzureWebJobsSecretStorageType': 'files', + 'FUNCTIONS_WORKER_RUNTIME': 'python' + } + + # In E2E Integration mode, we should use the core tools worker + # from the latest artifact instead of the azure_functions_worker module + if is_env_var_true(PYAZURE_INTEGRATION_TEST): + extra_env.pop('languageWorkers:python:workerDirectory') + + if testconfig and 'azure' in testconfig: + st = testconfig['azure'].get('storage_key') + if st: + extra_env['AzureWebJobsStorage'] = st + + cosmos = testconfig['azure'].get('cosmosdb_key') + if cosmos: + extra_env['AzureWebJobsCosmosDBConnectionString'] = cosmos + + eventhub = testconfig['azure'].get('eventhub_key') + if eventhub: + extra_env['AzureWebJobsEventHubConnectionString'] = eventhub + + servicebus = testconfig['azure'].get('servicebus_key') + if servicebus: + extra_env['AzureWebJobsServiceBusConnectionString'] = servicebus + + eventgrid_topic_uri = testconfig['azure'].get('eventgrid_topic_uri') + if eventgrid_topic_uri: + extra_env['AzureWebJobsEventGridTopicUri'] = eventgrid_topic_uri + + eventgrid_topic_key = testconfig['azure'].get('eventgrid_topic_key') + if eventgrid_topic_key: + extra_env['AzureWebJobsEventGridConnectionKey'] = \ + eventgrid_topic_key + + if port is not None: + extra_env['ASPNETCORE_URLS'] = f'http://*:{port}' + + return subprocess.Popen( + hostexe_args, + cwd=script_root, + env={ + **os.environ, + **extra_env, + }, + stdout=stdout, + stderr=stderr) + + +def start_webhost(*, script_dir=None, stdout=None): + script_root = STEIN_TESTS_ROOT / script_dir if script_dir else \ + HTTP_FUNCS_PATH + if stdout is None: + if is_env_var_true(PYAZURE_WEBHOST_DEBUG): + stdout = sys.stdout + else: + stdout = subprocess.DEVNULL + + port = _find_open_port() + proc = popen_webhost(stdout=stdout, stderr=subprocess.STDOUT, + script_root=script_root, port=port) + time.sleep(10) # Giving host some time to start fully. + addr = f'http://{LOCALHOST}:{port}' + + return _WebHostProxy(proc, addr) + + +class WebHostTestCaseMeta(type(unittest.TestCase)): + + def __new__(mcls, name, bases, dct): + for attrname, attr in dct.items(): + if attrname.startswith('test_') and callable(attr): + test_case = attr + check_log_name = attrname.replace('test_', 'check_log_', 1) + check_log_case = dct.get(check_log_name) + + @functools.wraps(test_case) + def wrapper(self, *args, __meth__=test_case, + __check_log__=check_log_case, **kwargs): + if (__check_log__ is not None + and callable(__check_log__) + and not is_env_var_true(PYAZURE_WEBHOST_DEBUG)): + + # Check logging output for unit test scenarios + result = self._run_test(__meth__, *args, **kwargs) + + # Trim off host output timestamps + host_output = getattr(self, 'host_out', '') + output_lines = host_output.splitlines() + ts_re = r"^\[\d+(\/|-)\d+(\/|-)\d+T*\d+\:\d+\:\d+.*(" \ + r"A|P)*M*\]" + output = list(map(lambda s: + re.sub(ts_re, '', s).strip(), + output_lines)) + + # Execute check_log_ test cases + self._run_test(__check_log__, host_out=output) + return result + else: + # Check normal unit test + return self._run_test(__meth__, *args, **kwargs) + + dct[attrname] = wrapper + + return super().__new__(mcls, name, bases, dct) + + +class WebHostTestCase(unittest.TestCase, metaclass=WebHostTestCaseMeta): + """Base class for integration tests that need a WebHost. + + In addition to automatically starting up a WebHost instance, + this test case class logs WebHost stdout/stderr in case + a unit test fails. + + You can write two sets of test - test_* and check_log_* tests. + + test_ABC - Unittest + check_log_ABC - Check logs generated during the execution of test_ABC. + """ + host_stdout_logger = logging.getLogger('webhosttests') + + @classmethod + def get_script_dir(cls): + raise NotImplementedError + + @classmethod + def setUpClass(cls): + script_dir = pathlib.Path(cls.get_script_dir()) + if is_env_var_true(PYAZURE_WEBHOST_DEBUG): + cls.host_stdout = None + else: + cls.host_stdout = tempfile.NamedTemporaryFile('w+t') + + _setup_func_app(STEIN_TESTS_ROOT / script_dir) + try: + cls.webhost = start_webhost(script_dir=script_dir, + stdout=cls.host_stdout) + except Exception: + _teardown_func_app(STEIN_TESTS_ROOT / script_dir) + raise + + @classmethod + def tearDownClass(cls): + cls.webhost.close() + cls.webhost = None + + if cls.host_stdout is not None: + cls.host_stdout.close() + cls.host_stdout = None + + script_dir = pathlib.Path(cls.get_script_dir()) + _teardown_func_app(STEIN_TESTS_ROOT / script_dir) + + def _run_test(self, test, *args, **kwargs): + if self.host_stdout is None: + test(self, *args, **kwargs) + else: + # Discard any host stdout left from the previous test or + # from the setup. + self.host_stdout.read() + last_pos = self.host_stdout.tell() + + test_exception = None + try: + test(self, *args, **kwargs) + except Exception as e: + test_exception = e + + try: + self.host_stdout.seek(last_pos) + self.host_out = self.host_stdout.read() + self.host_stdout_logger.error( + f'Captured WebHost stdout from {self.host_stdout.name} ' + f':\n{self.host_out}') + finally: + if test_exception is not None: + raise test_exception + + +def _setup_func_app(app_root): + extensions = app_root / 'bin' + host_json = app_root / 'host.json' + extensions_csproj_file = app_root / 'extensions.csproj' + + if not os.path.isfile(host_json): + with open(host_json, 'w') as f: + f.write(HOST_JSON_TEMPLATE) + + if not os.path.isfile(extensions_csproj_file): + with open(extensions_csproj_file, 'w') as f: + f.write(EXTENSION_CSPROJ_TEMPLATE) + + _symlink_dir(EXTENSIONS_PATH, extensions) + + +def _teardown_func_app(app_root): + extensions = app_root / 'bin' + host_json = app_root / 'host.json' + extensions_csproj_file = app_root / 'extensions.csproj' + extensions_obj_file = app_root / 'obj' + + for path in (extensions, host_json, extensions_csproj_file, + extensions_obj_file): + remove_path(path) + + +def _main(): + parser = argparse.ArgumentParser(description='Run a Python worker.') + parser.add_argument('scriptroot', + help='directory with functions to load') + + args = parser.parse_args() + + app_root = pathlib.Path(args.scriptroot) + _setup_func_app(app_root) + + host = popen_webhost( + stdout=sys.stdout, stderr=sys.stderr, + script_root=os.path.abspath(args.scriptroot)) + try: + host.wait() + finally: + host.terminate() + _teardown_func_app() + + +if __name__ == '__main__': + _main() diff --git a/tests/stein_tests/unittests/test_third_party_http_functions.py b/tests/stein_tests/unittests/test_third_party_http_functions.py new file mode 100644 index 000000000..70ba60676 --- /dev/null +++ b/tests/stein_tests/unittests/test_third_party_http_functions.py @@ -0,0 +1,221 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License +import filecmp +import os +import pathlib +import re +import typing +from unittest.mock import patch + +from tests.stein_tests import testutils +from tests.stein_tests.constants import UNIT_TESTS_ROOT + +HOST_JSON_TEMPLATE = """\ +{ + "version": "2.0", + "logging": { + "logLevel": { + "default": "Trace" + } + }, + "extensions": { + "http": { + "routePrefix": "" + } + }, + "functionTimeout": "00:05:00" +} +""" + + +class ThirdPartyHttpFunctionsTestBase: + class TestThirdPartyHttpFunctions(testutils.WebHostTestCase): + + @classmethod + def setUpClass(cls): + host_json = cls.get_script_dir() / 'host.json' + with open(host_json, 'w+') as f: + f.write(HOST_JSON_TEMPLATE) + os_environ = os.environ.copy() + # Turn on feature flag + os_environ['AzureWebJobsFeatureFlags'] = 'EnableWorkerIndexing' + cls._patch_environ = patch.dict('os.environ', os_environ) + cls._patch_environ.start() + super().setUpClass() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + cls._patch_environ.stop() + + @classmethod + def get_script_dir(cls): + pass + + def test_debug_logging(self): + r = self.webhost.request('GET', 'debug_logging', no_prefix=True) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.text, 'OK-debug') + + def check_log_debug_logging(self, host_out: typing.List[str]): + self.assertIn('logging info', host_out) + self.assertIn('logging warning', host_out) + self.assertIn('logging error', host_out) + self.assertNotIn('logging debug', host_out) + + def test_debug_with_user_logging(self): + r = self.webhost.request('GET', 'debug_user_logging', + no_prefix=True) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.text, 'OK-user-debug') + + def check_log_debug_with_user_logging(self, + host_out: typing.List[str]): + self.assertIn('logging info', host_out) + self.assertIn('logging warning', host_out) + self.assertIn('logging debug', host_out) + self.assertIn('logging error', host_out) + + def test_print_logging_no_flush(self): + r = self.webhost.request('GET', 'print_logging?message=Secret42', + no_prefix=True) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.text, 'OK-print-logging') + + def check_log_print_logging_no_flush(self, host_out: typing.List[str]): + self.assertIn('Secret42', host_out) + + def test_print_logging_with_flush(self): + r = self.webhost.request('GET', + 'print_logging?flush=true&message' + '=Secret42', + no_prefix=True) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.text, 'OK-print-logging') + + def check_log_print_logging_with_flush(self, + host_out: typing.List[str]): + self.assertIn('Secret42', host_out) + + def test_print_to_console_stdout(self): + r = self.webhost.request('GET', + 'print_logging?console=true&message' + '=Secret42', + no_prefix=True) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.text, 'OK-print-logging') + + def check_log_print_to_console_stdout(self, + host_out: typing.List[str]): + # System logs stdout should not exist in host_out + self.assertNotIn('Secret42', host_out) + + def test_print_to_console_stderr(self): + r = self.webhost.request('GET', 'print_logging?console=true' + '&message=Secret42&is_stderr=true', + no_prefix=True) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.text, 'OK-print-logging') + + def check_log_print_to_console_stderr(self, + host_out: typing.List[str], ): + # System logs stderr should not exist in host_out + self.assertNotIn('Secret42', host_out) + + def test_raw_body_bytes(self): + parent_dir = pathlib.Path(__file__).parent.parent.parent + image_file = parent_dir / 'unittests/resources/functions.png' + with open(image_file, 'rb') as image: + img = image.read() + img_len = len(img) + r = self.webhost.request('POST', 'raw_body_bytes', data=img, + no_prefix=True) + + received_body_len = int(r.headers['body-len']) + self.assertEqual(received_body_len, img_len) + + body = r.content + try: + received_img_file = parent_dir / 'received_img.png' + with open(received_img_file, 'wb') as received_img: + received_img.write(body) + self.assertTrue(filecmp.cmp(received_img_file, image_file)) + finally: + if (os.path.exists(received_img_file)): + os.remove(received_img_file) + + def test_return_http_no_body(self): + r = self.webhost.request('GET', 'return_http_no_body', + no_prefix=True) + self.assertEqual(r.text, '') + self.assertEqual(r.status_code, 200) + + def test_return_http_redirect(self): + r = self.webhost.request('GET', 'return_http_redirect', + no_prefix=True) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.text, '

Hello World™

') + + r = self.webhost.request('GET', 'return_http_redirect', + allow_redirects=False, no_prefix=True) + self.assertEqual(r.status_code, 302) + + def test_unhandled_error(self): + r = self.webhost.request('GET', 'unhandled_error', no_prefix=True) + self.assertEqual(r.status_code, 500) + # https://github.com/Azure/azure-functions-host/issues/2706 + # self.assertIn('ZeroDivisionError', r.text) + + def check_log_unhandled_error(self, + host_out: typing.List[str]): + r = re.compile(".*ZeroDivisionError: division by zero.*") + error_log = list(filter(r.match, host_out)) + self.assertGreaterEqual(len(error_log), 1) + + def test_unhandled_unserializable_error(self): + r = self.webhost.request( + 'GET', 'unhandled_unserializable_error', no_prefix=True) + self.assertEqual(r.status_code, 500) + + def test_unhandled_urllib_error(self): + r = self.webhost.request( + 'GET', 'unhandled_urllib_error', + params={'img': 'http://example.com/nonexistent.jpg'}, + no_prefix=True) + self.assertEqual(r.status_code, 500) + + +class TestAsgiHttpFunctions( + ThirdPartyHttpFunctionsTestBase.TestThirdPartyHttpFunctions): + @classmethod + def get_script_dir(cls): + return UNIT_TESTS_ROOT / 'third_party_http_functions' / 'asgi_function' + + def test_hijack_current_event_loop(self): + r = self.webhost.request('GET', 'hijack_current_event_loop', + no_prefix=True) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.text, 'OK-hijack-current-event-loop') + + def check_log_hijack_current_event_loop(self, + host_out: typing.List[str]): + # User logs should exist in host_out + self.assertIn('parallelly_print', host_out) + self.assertIn('parallelly_log_info at root logger', host_out) + self.assertIn('parallelly_log_warning at root logger', host_out) + self.assertIn('parallelly_log_error at root logger', host_out) + self.assertIn('parallelly_log_exception at root logger', + host_out) + self.assertIn('parallelly_log_custom at custom_logger', host_out) + self.assertIn('callsoon_log', host_out) + + # System logs should not exist in host_out + self.assertNotIn('parallelly_log_system at disguised_logger', + host_out) + + +class TestWsgiHttpFunctions( + ThirdPartyHttpFunctionsTestBase.TestThirdPartyHttpFunctions): + @classmethod + def get_script_dir(cls): + return UNIT_TESTS_ROOT / 'third_party_http_functions' / 'wsgi_function' diff --git a/tests/stein_tests/unittests/third_party_http_functions/asgi_function/function_app.py b/tests/stein_tests/unittests/third_party_http_functions/asgi_function/function_app.py new file mode 100644 index 000000000..c9c8d37b0 --- /dev/null +++ b/tests/stein_tests/unittests/third_party_http_functions/asgi_function/function_app.py @@ -0,0 +1,175 @@ +import asyncio +import logging +import sys +from urllib.request import urlopen + +import azure.functions as func +from fastapi import FastAPI, Request, Response +from fastapi.responses import RedirectResponse + +fast_app = FastAPI() +logger = logging.getLogger("my-function") +# Attempt to log info into system log from customer code +disguised_logger = logging.getLogger('azure_functions_worker') + + +async def parallelly_print(): + await asyncio.sleep(0.1) + print('parallelly_print') + + +async def parallelly_log_info(): + await asyncio.sleep(0.2) + logging.info('parallelly_log_info at root logger') + + +async def parallelly_log_warning(): + await asyncio.sleep(0.3) + logging.warning('parallelly_log_warning at root logger') + + +async def parallelly_log_error(): + await asyncio.sleep(0.4) + logging.error('parallelly_log_error at root logger') + + +async def parallelly_log_exception(): + await asyncio.sleep(0.5) + try: + raise Exception('custom exception') + except Exception: + logging.exception('parallelly_log_exception at root logger', + exc_info=sys.exc_info()) + + +async def parallelly_log_custom(): + await asyncio.sleep(0.6) + logger.info('parallelly_log_custom at custom_logger') + + +async def parallelly_log_system(): + await asyncio.sleep(0.7) + disguised_logger.info('parallelly_log_system at disguised_logger') + + +@fast_app.get("/debug_logging") +async def debug_logging(): + logging.critical('logging critical', exc_info=True) + logging.info('logging info', exc_info=True) + logging.warning('logging warning', exc_info=True) + logging.debug('logging debug', exc_info=True) + logging.error('logging error', exc_info=True) + + return Response(content='OK-debug', media_type="text/plain") + + +@fast_app.get("/debug_user_logging") +async def debug_user_logging(): + logger.setLevel(logging.DEBUG) + + logger.critical('logging critical', exc_info=True) + logger.info('logging info', exc_info=True) + logger.warning('logging warning', exc_info=True) + logger.debug('logging debug', exc_info=True) + logger.error('logging error', exc_info=True) + + return Response(content='OK-user-debug', media_type="text/plain") + + +@fast_app.get("/hijack_current_event_loop") +async def hijack_current_event_loop(): + loop = asyncio.get_event_loop() + + # Create multiple tasks and schedule it into one asyncio.wait blocker + task_print: asyncio.Task = loop.create_task(parallelly_print()) + task_info: asyncio.Task = loop.create_task(parallelly_log_info()) + task_warning: asyncio.Task = loop.create_task(parallelly_log_warning()) + task_error: asyncio.Task = loop.create_task(parallelly_log_error()) + task_exception: asyncio.Task = loop.create_task(parallelly_log_exception()) + task_custom: asyncio.Task = loop.create_task(parallelly_log_custom()) + task_disguise: asyncio.Task = loop.create_task(parallelly_log_system()) + + # Create an awaitable future and occupy the current event loop resource + future = loop.create_future() + loop.call_soon_threadsafe(future.set_result, 'callsoon_log') + + # WaitAll + await asyncio.wait([task_print, task_info, task_warning, task_error, + task_exception, task_custom, task_disguise, future]) + + # Log asyncio low-level future result + logging.info(future.result()) + + return Response(content='OK-hijack-current-event-loop', + media_type="text/plain") + + +@fast_app.get("/print_logging") +async def print_logging(message: str = "", flush: str = 'false', + console: str = 'false', is_stderr: str = 'false'): + flush_required = False + is_console_log = False + is_stderr = False + + if flush == 'true': + flush_required = True + if console == 'true': + is_console_log = True + if is_stderr == 'true': + is_stderr = True + + # Adding LanguageWorkerConsoleLog will make function host to treat + # this as system log and will be propagated to kusto + prefix = 'LanguageWorkerConsoleLog' if is_console_log else '' + print(f'{prefix} {message}'.strip(), + file=sys.stderr if is_stderr else sys.stdout, + flush=flush_required) + + return Response(content='OK-print-logging', media_type="text/plain") + + +@fast_app.post("/raw_body_bytes") +async def raw_body_bytes(request: Request): + raw_body = await request.body() + return Response(content=raw_body, headers={'body-len': str(len(raw_body))}) + + +@fast_app.get("/return_http_no_body") +async def return_http_no_body(): + return Response(content='', media_type="text/plain") + + +@fast_app.get("/return_http") +async def return_http(request: Request): + return Response('

Hello World™

', media_type='text/html') + + +@fast_app.get("/return_http_redirect") +async def return_http_redirect(request: Request, code: str = ''): + location = 'return_http?code={}'.format(code) + return RedirectResponse(status_code=302, + url=f"http://{request.url.components[1]}/" + f"{location}") + + +@fast_app.get("/unhandled_error") +async def unhandled_error(): + 1 / 0 + + +@fast_app.get("/unhandled_urllib_error") +async def unhandled_urllib_error(img: str = ''): + urlopen(img).read() + + +class UnserializableException(Exception): + def __str__(self): + raise RuntimeError('cannot serialize me') + + +@fast_app.get("/unhandled_unserializable_error") +async def unhandled_unserializable_error(): + raise UnserializableException('foo') + + +app = func.FunctionApp(asgi_app=fast_app, auth_level=func.AuthLevel.ANONYMOUS) diff --git a/tests/stein_tests/unittests/third_party_http_functions/wsgi_function/function_app.py b/tests/stein_tests/unittests/third_party_http_functions/wsgi_function/function_app.py new file mode 100644 index 000000000..c17bea0cd --- /dev/null +++ b/tests/stein_tests/unittests/third_party_http_functions/wsgi_function/function_app.py @@ -0,0 +1,103 @@ +import logging +import sys +from urllib.request import urlopen + +import azure.functions as func +from flask import Flask, request, Response, redirect, url_for + +flask_app = Flask(__name__) +logger = logging.getLogger("my-function") + + +@flask_app.get("/debug_logging") +def debug_logging(): + logging.critical('logging critical', exc_info=True) + logging.info('logging info', exc_info=True) + logging.warning('logging warning', exc_info=True) + logging.debug('logging debug', exc_info=True) + logging.error('logging error', exc_info=True) + + return 'OK-debug' + + +@flask_app.get("/debug_user_logging") +def debug_user_logging(): + logger.setLevel(logging.DEBUG) + + logger.critical('logging critical', exc_info=True) + logger.info('logging info', exc_info=True) + logger.warning('logging warning', exc_info=True) + logger.debug('logging debug', exc_info=True) + logger.error('logging error', exc_info=True) + return 'OK-user-debug' + + +@flask_app.get("/print_logging") +def print_logging(): + flush_required = False + is_console_log = False + is_stderr = False + + message = request.args.get("message", '') + + if request.args.get("flush") == 'true': + flush_required = True + if request.args.get("console") == 'true': + is_console_log = True + if request.args.get("is_stderr") == 'true': + is_stderr = True + + # Adding LanguageWorkerConsoleLog will make function host to treat + # this as system log and will be propagated to kusto + prefix = 'LanguageWorkerConsoleLog' if is_console_log else '' + print(f'{prefix} {message}'.strip(), + file=sys.stderr if is_stderr else sys.stdout, + flush=flush_required) + + return 'OK-print-logging' + + +@flask_app.post("/raw_body_bytes") +def raw_body_bytes(): + body = request.get_data() + + return Response(body, headers={'body-len': str(len(body))}) + + +@flask_app.get("/return_http_no_body") +def return_http_no_body(): + return '' + + +@flask_app.get("/return_http") +def return_http(): + return Response('

Hello World™

', mimetype='text/html') + + +@flask_app.get("/return_http_redirect") +def return_http_redirect(code: str = ''): + return redirect(url_for('return_http')) + + +@flask_app.get("/unhandled_error") +def unhandled_error(): + 1 / 0 + + +@flask_app.get("/unhandled_urllib_error") +def unhandled_urllib_error(img: str = ''): + urlopen(img).read() + + +class UnserializableException(Exception): + def __str__(self): + raise RuntimeError('cannot serialize me') + + +@flask_app.get("/unhandled_unserializable_error") +def unhandled_unserializable_error(): + raise UnserializableException('foo') + + +app = func.FunctionApp(wsgi_app=flask_app.wsgi_app, + auth_level=func.AuthLevel.ANONYMOUS) From 3116e112b61b5b32978c35fd9ba30acb50021d2d Mon Sep 17 00:00:00 2001 From: peterstone2017 Date: Tue, 8 Mar 2022 09:30:56 -0600 Subject: [PATCH 2/6] revert local chg --- .flake8 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.flake8 b/.flake8 index ba9246785..74e4d83b7 100644 --- a/.flake8 +++ b/.flake8 @@ -10,6 +10,6 @@ exclude = .git, __pycache__, build, dist, .eggs, .github, .local, docs/, azure_functions_worker/_thirdparty/typing_inspect.py, tests/unittests/test_typing_inspect.py, tests/unittests/broken_functions/syntax_error/main.py, - .env*, .vscode, venv*, *.venv*, worker_env + .env*, .vscode, venv*, *.venv* max-line-length = 80 From 3972294c84cdaf46ca7b55dd23f600c7cf753c71 Mon Sep 17 00:00:00 2001 From: peterstone2017 Date: Mon, 2 May 2022 15:04:03 -0500 Subject: [PATCH 3/6] update asgi/wsgi stein tests --- azure_functions_worker/testutils.py | 7 +- .../test_third_party_http_functions.py | 18 +- .../stein}/asgi_function/function_app.py | 3 +- .../stein}/wsgi_function/function_app.py | 2 +- tests/stein_tests/testutils.py | 471 ------------------ .../test_third_party_http_functions.py | 12 +- .../stein}/asgi_function/function_app.py | 3 +- .../stein}/wsgi_function/function_app.py | 2 +- 8 files changed, 29 insertions(+), 489 deletions(-) rename tests/{stein_tests/endtoendtests => endtoend}/test_third_party_http_functions.py (92%) rename tests/{stein_tests/endtoendtests/third_party_http_functions => endtoend/third_party_http_functions/stein}/asgi_function/function_app.py (89%) rename tests/{stein_tests/endtoendtests/third_party_http_functions => endtoend/third_party_http_functions/stein}/wsgi_function/function_app.py (91%) delete mode 100644 tests/stein_tests/testutils.py rename tests/{stein_tests => }/unittests/test_third_party_http_functions.py (97%) rename tests/{stein_tests/unittests/third_party_http_functions => unittests/third_party_http_functions/stein}/asgi_function/function_app.py (98%) rename tests/{stein_tests/unittests/third_party_http_functions => unittests/third_party_http_functions/stein}/wsgi_function/function_app.py (97%) diff --git a/azure_functions_worker/testutils.py b/azure_functions_worker/testutils.py index e06a01386..62e14a2e3 100644 --- a/azure_functions_worker/testutils.py +++ b/azure_functions_worker/testutils.py @@ -745,10 +745,13 @@ def __init__(self, proc, addr): def request(self, meth, funcname, *args, **kwargs): request_method = getattr(requests, meth.lower()) params = dict(kwargs.pop('params', {})) + no_prefix = kwargs.pop('no_prefix', False) if 'code' not in params: params['code'] = 'testFunctionKey' - return request_method(self._addr + '/api/' + funcname, - *args, params=params, **kwargs) + + return request_method( + self._addr + ('/' if no_prefix else '/api/') + funcname, + *args, params=params, **kwargs) def close(self): if self._proc.stdout: diff --git a/tests/stein_tests/endtoendtests/test_third_party_http_functions.py b/tests/endtoend/test_third_party_http_functions.py similarity index 92% rename from tests/stein_tests/endtoendtests/test_third_party_http_functions.py rename to tests/endtoend/test_third_party_http_functions.py index e3691f170..e130e4309 100644 --- a/tests/stein_tests/endtoendtests/test_third_party_http_functions.py +++ b/tests/endtoend/test_third_party_http_functions.py @@ -5,9 +5,11 @@ import requests -from azure_functions_worker import testutils as utils -from tests.stein_tests import testutils -from tests.stein_tests.constants import E2E_TESTS_ROOT +from azure_functions_worker import testutils as utils, testutils + +# from tests.stein_tests import testutils +# from tests.stein_tests.constants import E2E_TESTS_ROOT +from azure_functions_worker.testutils import E2E_TESTS_ROOT HOST_JSON_TEMPLATE = """\ { @@ -148,14 +150,16 @@ def test_raise_exception_should_return_not_found(self): class TestAsgiHttpFunctions( - ThirdPartyHttpFunctionsTestBase.TestThirdPartyHttpFunctions): + ThirdPartyHttpFunctionsTestBase.TestThirdPartyHttpFunctions): @classmethod def get_script_dir(cls): - return E2E_TESTS_ROOT / 'third_party_http_functions' / 'asgi_function' + return E2E_TESTS_ROOT / 'third_party_http_functions' / 'stein' / \ + 'asgi_function' class TestWsgiHttpFunctions( - ThirdPartyHttpFunctionsTestBase.TestThirdPartyHttpFunctions): + ThirdPartyHttpFunctionsTestBase.TestThirdPartyHttpFunctions): @classmethod def get_script_dir(cls): - return E2E_TESTS_ROOT / 'third_party_http_functions' / 'wsgi_function' + return E2E_TESTS_ROOT / 'third_party_http_functions' / 'stein' / \ + 'wsgi_function' diff --git a/tests/stein_tests/endtoendtests/third_party_http_functions/asgi_function/function_app.py b/tests/endtoend/third_party_http_functions/stein/asgi_function/function_app.py similarity index 89% rename from tests/stein_tests/endtoendtests/third_party_http_functions/asgi_function/function_app.py rename to tests/endtoend/third_party_http_functions/stein/asgi_function/function_app.py index bf55de7e9..0654b349a 100644 --- a/tests/stein_tests/endtoendtests/third_party_http_functions/asgi_function/function_app.py +++ b/tests/endtoend/third_party_http_functions/stein/asgi_function/function_app.py @@ -37,4 +37,5 @@ async def raise_http_exception(): raise HTTPException(status_code=404, detail="Item not found") -app = func.FunctionApp(asgi_app=fast_app, auth_level=func.AuthLevel.ANONYMOUS) +app = func.FunctionApp(asgi_app=fast_app, + http_auth_level=func.AuthLevel.ANONYMOUS) diff --git a/tests/stein_tests/endtoendtests/third_party_http_functions/wsgi_function/function_app.py b/tests/endtoend/third_party_http_functions/stein/wsgi_function/function_app.py similarity index 91% rename from tests/stein_tests/endtoendtests/third_party_http_functions/wsgi_function/function_app.py rename to tests/endtoend/third_party_http_functions/stein/wsgi_function/function_app.py index 3c8e98cec..267fb85ef 100644 --- a/tests/stein_tests/endtoendtests/third_party_http_functions/wsgi_function/function_app.py +++ b/tests/endtoend/third_party_http_functions/stein/wsgi_function/function_app.py @@ -33,4 +33,4 @@ def raise_http_exception(): app = func.FunctionApp(wsgi_app=flask_app.wsgi_app, - auth_level=func.AuthLevel.ANONYMOUS) + http_auth_level=func.AuthLevel.ANONYMOUS) diff --git a/tests/stein_tests/testutils.py b/tests/stein_tests/testutils.py deleted file mode 100644 index 2c98fd682..000000000 --- a/tests/stein_tests/testutils.py +++ /dev/null @@ -1,471 +0,0 @@ -import argparse -import configparser -import functools -import logging -import os -import pathlib -import platform -import re -import shutil -import socket -import subprocess -import sys -import tempfile -import time -import unittest - -import requests -from tests.stein_tests.constants import PYAZURE_WEBHOST_DEBUG, \ - PYAZURE_WORKER_DIR, \ - PYAZURE_INTEGRATION_TEST, PROJECT_ROOT, STEIN_TESTS_ROOT, WORKER_CONFIG, \ - LOCALHOST, WORKER_PATH, HTTP_FUNCS_PATH - -EXTENSIONS_PATH = PROJECT_ROOT / 'build' / 'extensions' / 'bin' -WEBHOST_DLL = "Microsoft.Azure.WebJobs.Script.WebHost.dll" -DEFAULT_WEBHOST_DLL_PATH = ( - PROJECT_ROOT / 'build' / 'webhost' / 'bin' / WEBHOST_DLL -) - - -# The template of host.json that will be applied to each test functions -HOST_JSON_TEMPLATE = """\ -{ - "version": "2.0", - "logging": {"logLevel": {"default": "Trace"}} -} -""" - -EXTENSION_CSPROJ_TEMPLATE = """\ - - - netcoreapp3.1 - - ** - - - - - - - - - - - -""" - -SECRETS_TEMPLATE = """\ -{ - "masterKey": { - "name": "master", - "value": "testMasterKey", - "encrypted": false - }, - "functionKeys": [ - { - "name": "default", - "value": "testFunctionKey", - "encrypted": false - } - ], - "systemKeys": [ - { - "name": "eventgridextensionconfig_extension", - "value": "testSystemKey", - "encrypted": false - } - ], - "hostName": null, - "instanceId": "0000000000000000000000001C69C103", - "source": "runtime" -} -""" - - -def is_env_var_true(env_key: str) -> bool: - if os.getenv(env_key) is None: - return False - - return is_true_like(os.environ[env_key]) - - -def is_true_like(setting: str) -> bool: - if setting is None: - return False - - return setting.lower().strip() in ['1', 'true', 't', 'yes', 'y'] - - -def remove_path(path): - if path.is_symlink(): - path.unlink() - elif path.is_dir(): - shutil.rmtree(str(path)) - elif path.exists(): - path.unlink() - - -def _symlink_dir(src, dst): - remove_path(dst) - - if platform.system() == 'Windows': - shutil.copytree(str(src), str(dst)) - else: - dst.symlink_to(src, target_is_directory=True) - - -class _WebHostProxy: - - def __init__(self, proc, addr): - self._proc = proc - self._addr = addr - - def request(self, meth, funcname, *args, **kwargs): - request_method = getattr(requests, meth.lower()) - params = dict(kwargs.pop('params', {})) - no_prefix = kwargs.pop('no_prefix', False) - if 'code' not in params: - params['code'] = 'testFunctionKey' - - return request_method( - self._addr + ('/' if no_prefix else '/api/') + funcname, - *args, params=params, **kwargs) - - def close(self): - if self._proc.stdout: - self._proc.stdout.close() - if self._proc.stderr: - self._proc.stderr.close() - - self._proc.terminate() - try: - self._proc.wait(20) - except subprocess.TimeoutExpired: - self._proc.kill() - - -def _find_open_port(): - with socket.socket() as s: - s.bind((LOCALHOST, 0)) - s.listen(1) - return s.getsockname()[1] - - -def popen_webhost(*, stdout, stderr, script_root=HTTP_FUNCS_PATH, port=None): - testconfig = None - if WORKER_CONFIG.exists(): - testconfig = configparser.ConfigParser() - testconfig.read(WORKER_CONFIG) - - hostexe_args = [] - - os.environ['AzureWebJobsFeatureFlags'] = 'EnableWorkerIndexing' - - # If we want to use core-tools - coretools_exe = os.environ.get('CORE_TOOLS_EXE_PATH') - if coretools_exe: - coretools_exe = coretools_exe.strip() - if pathlib.Path(coretools_exe).exists(): - hostexe_args = [str(coretools_exe), 'host', 'start'] - if port is not None: - hostexe_args.extend(['--port', str(port)]) - - # If we need to use Functions host directly - if not hostexe_args: - dll = os.environ.get('PYAZURE_WEBHOST_DLL') - if not dll and testconfig and testconfig.has_section('webhost'): - dll = testconfig['webhost'].get('dll') - - if dll: - # Paths from environment might contain trailing - # or leading whitespace. - dll = dll.strip() - - if not dll: - dll = DEFAULT_WEBHOST_DLL_PATH - - os.makedirs(dll.parent / 'Secrets', exist_ok=True) - with open(dll.parent / 'Secrets' / 'host.json', 'w') as f: - secrets = SECRETS_TEMPLATE - - f.write(secrets) - - if dll and pathlib.Path(dll).exists(): - hostexe_args = ['dotnet', str(dll)] - - if not hostexe_args: - raise RuntimeError('\n'.join([ - 'Unable to locate Azure Functions Host binary.', - 'Please do one of the following:', - ' * run the following command from the root folder of', - ' the project:', - '', - f' $ {sys.executable} setup.py webhost', - '', - ' * or download or build the Azure Functions Host and' - ' then write the full path to WebHost.dll' - ' into the `PYAZURE_WEBHOST_DLL` environment variable.', - ' Alternatively, you can create the', - f' {WORKER_CONFIG.name} file in the root folder', - ' of the project with the following structure:', - '', - ' [webhost]', - ' dll = /path/Microsoft.Azure.WebJobs.Script.WebHost.dll', - ' * or download Azure Functions Core Tools binaries and', - ' then write the full path to func.exe into the ', - ' `CORE_TOOLS_EXE_PATH` envrionment variable.', - '', - 'Setting "export PYAZURE_WEBHOST_DEBUG=true" to get the full', - 'stdout and stderr from function host.' - ])) - - worker_path = os.environ.get(PYAZURE_WORKER_DIR) - worker_path = WORKER_PATH if not worker_path else pathlib.Path(worker_path) - if not worker_path.exists(): - raise RuntimeError(f'Worker path {worker_path} does not exist') - - # Casting to strings is necessary because Popen doesn't like - # path objects there on Windows. - extra_env = { - 'AzureWebJobsScriptRoot': str(script_root), - 'languageWorkers:python:workerDirectory': str(worker_path), - 'host:logger:consoleLoggingMode': 'always', - 'AZURE_FUNCTIONS_ENVIRONMENT': 'development', - 'AzureWebJobsSecretStorageType': 'files', - 'FUNCTIONS_WORKER_RUNTIME': 'python' - } - - # In E2E Integration mode, we should use the core tools worker - # from the latest artifact instead of the azure_functions_worker module - if is_env_var_true(PYAZURE_INTEGRATION_TEST): - extra_env.pop('languageWorkers:python:workerDirectory') - - if testconfig and 'azure' in testconfig: - st = testconfig['azure'].get('storage_key') - if st: - extra_env['AzureWebJobsStorage'] = st - - cosmos = testconfig['azure'].get('cosmosdb_key') - if cosmos: - extra_env['AzureWebJobsCosmosDBConnectionString'] = cosmos - - eventhub = testconfig['azure'].get('eventhub_key') - if eventhub: - extra_env['AzureWebJobsEventHubConnectionString'] = eventhub - - servicebus = testconfig['azure'].get('servicebus_key') - if servicebus: - extra_env['AzureWebJobsServiceBusConnectionString'] = servicebus - - eventgrid_topic_uri = testconfig['azure'].get('eventgrid_topic_uri') - if eventgrid_topic_uri: - extra_env['AzureWebJobsEventGridTopicUri'] = eventgrid_topic_uri - - eventgrid_topic_key = testconfig['azure'].get('eventgrid_topic_key') - if eventgrid_topic_key: - extra_env['AzureWebJobsEventGridConnectionKey'] = \ - eventgrid_topic_key - - if port is not None: - extra_env['ASPNETCORE_URLS'] = f'http://*:{port}' - - return subprocess.Popen( - hostexe_args, - cwd=script_root, - env={ - **os.environ, - **extra_env, - }, - stdout=stdout, - stderr=stderr) - - -def start_webhost(*, script_dir=None, stdout=None): - script_root = STEIN_TESTS_ROOT / script_dir if script_dir else \ - HTTP_FUNCS_PATH - if stdout is None: - if is_env_var_true(PYAZURE_WEBHOST_DEBUG): - stdout = sys.stdout - else: - stdout = subprocess.DEVNULL - - port = _find_open_port() - proc = popen_webhost(stdout=stdout, stderr=subprocess.STDOUT, - script_root=script_root, port=port) - time.sleep(10) # Giving host some time to start fully. - addr = f'http://{LOCALHOST}:{port}' - - return _WebHostProxy(proc, addr) - - -class WebHostTestCaseMeta(type(unittest.TestCase)): - - def __new__(mcls, name, bases, dct): - for attrname, attr in dct.items(): - if attrname.startswith('test_') and callable(attr): - test_case = attr - check_log_name = attrname.replace('test_', 'check_log_', 1) - check_log_case = dct.get(check_log_name) - - @functools.wraps(test_case) - def wrapper(self, *args, __meth__=test_case, - __check_log__=check_log_case, **kwargs): - if (__check_log__ is not None - and callable(__check_log__) - and not is_env_var_true(PYAZURE_WEBHOST_DEBUG)): - - # Check logging output for unit test scenarios - result = self._run_test(__meth__, *args, **kwargs) - - # Trim off host output timestamps - host_output = getattr(self, 'host_out', '') - output_lines = host_output.splitlines() - ts_re = r"^\[\d+(\/|-)\d+(\/|-)\d+T*\d+\:\d+\:\d+.*(" \ - r"A|P)*M*\]" - output = list(map(lambda s: - re.sub(ts_re, '', s).strip(), - output_lines)) - - # Execute check_log_ test cases - self._run_test(__check_log__, host_out=output) - return result - else: - # Check normal unit test - return self._run_test(__meth__, *args, **kwargs) - - dct[attrname] = wrapper - - return super().__new__(mcls, name, bases, dct) - - -class WebHostTestCase(unittest.TestCase, metaclass=WebHostTestCaseMeta): - """Base class for integration tests that need a WebHost. - - In addition to automatically starting up a WebHost instance, - this test case class logs WebHost stdout/stderr in case - a unit test fails. - - You can write two sets of test - test_* and check_log_* tests. - - test_ABC - Unittest - check_log_ABC - Check logs generated during the execution of test_ABC. - """ - host_stdout_logger = logging.getLogger('webhosttests') - - @classmethod - def get_script_dir(cls): - raise NotImplementedError - - @classmethod - def setUpClass(cls): - script_dir = pathlib.Path(cls.get_script_dir()) - if is_env_var_true(PYAZURE_WEBHOST_DEBUG): - cls.host_stdout = None - else: - cls.host_stdout = tempfile.NamedTemporaryFile('w+t') - - _setup_func_app(STEIN_TESTS_ROOT / script_dir) - try: - cls.webhost = start_webhost(script_dir=script_dir, - stdout=cls.host_stdout) - except Exception: - _teardown_func_app(STEIN_TESTS_ROOT / script_dir) - raise - - @classmethod - def tearDownClass(cls): - cls.webhost.close() - cls.webhost = None - - if cls.host_stdout is not None: - cls.host_stdout.close() - cls.host_stdout = None - - script_dir = pathlib.Path(cls.get_script_dir()) - _teardown_func_app(STEIN_TESTS_ROOT / script_dir) - - def _run_test(self, test, *args, **kwargs): - if self.host_stdout is None: - test(self, *args, **kwargs) - else: - # Discard any host stdout left from the previous test or - # from the setup. - self.host_stdout.read() - last_pos = self.host_stdout.tell() - - test_exception = None - try: - test(self, *args, **kwargs) - except Exception as e: - test_exception = e - - try: - self.host_stdout.seek(last_pos) - self.host_out = self.host_stdout.read() - self.host_stdout_logger.error( - f'Captured WebHost stdout from {self.host_stdout.name} ' - f':\n{self.host_out}') - finally: - if test_exception is not None: - raise test_exception - - -def _setup_func_app(app_root): - extensions = app_root / 'bin' - host_json = app_root / 'host.json' - extensions_csproj_file = app_root / 'extensions.csproj' - - if not os.path.isfile(host_json): - with open(host_json, 'w') as f: - f.write(HOST_JSON_TEMPLATE) - - if not os.path.isfile(extensions_csproj_file): - with open(extensions_csproj_file, 'w') as f: - f.write(EXTENSION_CSPROJ_TEMPLATE) - - _symlink_dir(EXTENSIONS_PATH, extensions) - - -def _teardown_func_app(app_root): - extensions = app_root / 'bin' - host_json = app_root / 'host.json' - extensions_csproj_file = app_root / 'extensions.csproj' - extensions_obj_file = app_root / 'obj' - - for path in (extensions, host_json, extensions_csproj_file, - extensions_obj_file): - remove_path(path) - - -def _main(): - parser = argparse.ArgumentParser(description='Run a Python worker.') - parser.add_argument('scriptroot', - help='directory with functions to load') - - args = parser.parse_args() - - app_root = pathlib.Path(args.scriptroot) - _setup_func_app(app_root) - - host = popen_webhost( - stdout=sys.stdout, stderr=sys.stderr, - script_root=os.path.abspath(args.scriptroot)) - try: - host.wait() - finally: - host.terminate() - _teardown_func_app() - - -if __name__ == '__main__': - _main() diff --git a/tests/stein_tests/unittests/test_third_party_http_functions.py b/tests/unittests/test_third_party_http_functions.py similarity index 97% rename from tests/stein_tests/unittests/test_third_party_http_functions.py rename to tests/unittests/test_third_party_http_functions.py index 70ba60676..c78b6e200 100644 --- a/tests/stein_tests/unittests/test_third_party_http_functions.py +++ b/tests/unittests/test_third_party_http_functions.py @@ -7,8 +7,8 @@ import typing from unittest.mock import patch -from tests.stein_tests import testutils -from tests.stein_tests.constants import UNIT_TESTS_ROOT +from azure_functions_worker import testutils +from azure_functions_worker.testutils import UNIT_TESTS_ROOT HOST_JSON_TEMPLATE = """\ { @@ -123,7 +123,7 @@ def check_log_print_to_console_stderr(self, self.assertNotIn('Secret42', host_out) def test_raw_body_bytes(self): - parent_dir = pathlib.Path(__file__).parent.parent.parent + parent_dir = pathlib.Path(__file__).parent.parent image_file = parent_dir / 'unittests/resources/functions.png' with open(image_file, 'rb') as image: img = image.read() @@ -189,7 +189,8 @@ class TestAsgiHttpFunctions( ThirdPartyHttpFunctionsTestBase.TestThirdPartyHttpFunctions): @classmethod def get_script_dir(cls): - return UNIT_TESTS_ROOT / 'third_party_http_functions' / 'asgi_function' + return UNIT_TESTS_ROOT / 'third_party_http_functions' / 'stein' / \ + 'asgi_function' def test_hijack_current_event_loop(self): r = self.webhost.request('GET', 'hijack_current_event_loop', @@ -218,4 +219,5 @@ class TestWsgiHttpFunctions( ThirdPartyHttpFunctionsTestBase.TestThirdPartyHttpFunctions): @classmethod def get_script_dir(cls): - return UNIT_TESTS_ROOT / 'third_party_http_functions' / 'wsgi_function' + return UNIT_TESTS_ROOT / 'third_party_http_functions' / 'stein' / \ + 'wsgi_function' diff --git a/tests/stein_tests/unittests/third_party_http_functions/asgi_function/function_app.py b/tests/unittests/third_party_http_functions/stein/asgi_function/function_app.py similarity index 98% rename from tests/stein_tests/unittests/third_party_http_functions/asgi_function/function_app.py rename to tests/unittests/third_party_http_functions/stein/asgi_function/function_app.py index c9c8d37b0..7ac326783 100644 --- a/tests/stein_tests/unittests/third_party_http_functions/asgi_function/function_app.py +++ b/tests/unittests/third_party_http_functions/stein/asgi_function/function_app.py @@ -172,4 +172,5 @@ async def unhandled_unserializable_error(): raise UnserializableException('foo') -app = func.FunctionApp(asgi_app=fast_app, auth_level=func.AuthLevel.ANONYMOUS) +app = func.FunctionApp(asgi_app=fast_app, + http_auth_level=func.AuthLevel.ANONYMOUS) diff --git a/tests/stein_tests/unittests/third_party_http_functions/wsgi_function/function_app.py b/tests/unittests/third_party_http_functions/stein/wsgi_function/function_app.py similarity index 97% rename from tests/stein_tests/unittests/third_party_http_functions/wsgi_function/function_app.py rename to tests/unittests/third_party_http_functions/stein/wsgi_function/function_app.py index c17bea0cd..dbd2c311a 100644 --- a/tests/stein_tests/unittests/third_party_http_functions/wsgi_function/function_app.py +++ b/tests/unittests/third_party_http_functions/stein/wsgi_function/function_app.py @@ -100,4 +100,4 @@ def unhandled_unserializable_error(): app = func.FunctionApp(wsgi_app=flask_app.wsgi_app, - auth_level=func.AuthLevel.ANONYMOUS) + http_auth_level=func.AuthLevel.ANONYMOUS) From 875c3fba7960fe4820daa6475e585df5178e80c2 Mon Sep 17 00:00:00 2001 From: peterstone2017 Date: Mon, 2 May 2022 16:20:57 -0500 Subject: [PATCH 4/6] fix flakey8 --- tests/endtoend/test_third_party_http_functions.py | 8 ++++---- tests/unittests/test_third_party_http_functions.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/endtoend/test_third_party_http_functions.py b/tests/endtoend/test_third_party_http_functions.py index e130e4309..4301c5c7c 100644 --- a/tests/endtoend/test_third_party_http_functions.py +++ b/tests/endtoend/test_third_party_http_functions.py @@ -150,16 +150,16 @@ def test_raise_exception_should_return_not_found(self): class TestAsgiHttpFunctions( - ThirdPartyHttpFunctionsTestBase.TestThirdPartyHttpFunctions): + ThirdPartyHttpFunctionsTestBase.TestThirdPartyHttpFunctions): @classmethod def get_script_dir(cls): return E2E_TESTS_ROOT / 'third_party_http_functions' / 'stein' / \ - 'asgi_function' + 'asgi_function' class TestWsgiHttpFunctions( - ThirdPartyHttpFunctionsTestBase.TestThirdPartyHttpFunctions): + ThirdPartyHttpFunctionsTestBase.TestThirdPartyHttpFunctions): @classmethod def get_script_dir(cls): return E2E_TESTS_ROOT / 'third_party_http_functions' / 'stein' / \ - 'wsgi_function' + 'wsgi_function' diff --git a/tests/unittests/test_third_party_http_functions.py b/tests/unittests/test_third_party_http_functions.py index c78b6e200..8e2aca4e3 100644 --- a/tests/unittests/test_third_party_http_functions.py +++ b/tests/unittests/test_third_party_http_functions.py @@ -190,7 +190,7 @@ class TestAsgiHttpFunctions( @classmethod def get_script_dir(cls): return UNIT_TESTS_ROOT / 'third_party_http_functions' / 'stein' / \ - 'asgi_function' + 'asgi_function' def test_hijack_current_event_loop(self): r = self.webhost.request('GET', 'hijack_current_event_loop', @@ -220,4 +220,4 @@ class TestWsgiHttpFunctions( @classmethod def get_script_dir(cls): return UNIT_TESTS_ROOT / 'third_party_http_functions' / 'stein' / \ - 'wsgi_function' + 'wsgi_function' From 9360e0b4d11f39b1a5a3138e05936a5eaa530f40 Mon Sep 17 00:00:00 2001 From: peterstone2017 <12449837+YunchuWang@users.noreply.github.com> Date: Wed, 4 May 2022 14:42:41 -0500 Subject: [PATCH 5/6] Update test_third_party_http_functions.py --- tests/endtoend/test_third_party_http_functions.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/endtoend/test_third_party_http_functions.py b/tests/endtoend/test_third_party_http_functions.py index 4301c5c7c..7e40945ab 100644 --- a/tests/endtoend/test_third_party_http_functions.py +++ b/tests/endtoend/test_third_party_http_functions.py @@ -30,6 +30,9 @@ class ThirdPartyHttpFunctionsTestBase: + """Base test class containing common asgi/wsgi testcases, + only testcases in classes extending TestThirdPartyHttpFunctions + will by run""" class TestThirdPartyHttpFunctions(testutils.WebHostTestCase): @classmethod def setUpClass(cls): From 9c696ed0728beedaaa69c2d763122c95a72c232d Mon Sep 17 00:00:00 2001 From: peterstone2017 Date: Wed, 4 May 2022 15:01:17 -0500 Subject: [PATCH 6/6] flakey8 --- tests/endtoend/test_third_party_http_functions.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/endtoend/test_third_party_http_functions.py b/tests/endtoend/test_third_party_http_functions.py index 7e40945ab..8ca63f325 100644 --- a/tests/endtoend/test_third_party_http_functions.py +++ b/tests/endtoend/test_third_party_http_functions.py @@ -30,9 +30,9 @@ class ThirdPartyHttpFunctionsTestBase: - """Base test class containing common asgi/wsgi testcases, - only testcases in classes extending TestThirdPartyHttpFunctions - will by run""" + """Base test class containing common asgi/wsgi testcases, only testcases + in classes extending TestThirdPartyHttpFunctions will by run""" + class TestThirdPartyHttpFunctions(testutils.WebHostTestCase): @classmethod def setUpClass(cls):