Skip to content

Commit 0b21dc0

Browse files
hallvictoriavrdmrgavin-aguiar
authored
Configurable file name (Azure#1323)
* configurable file name * lint & error fix * test fix * fix merge lint errors * e2e and loader test * test linting errors * test linting errors * e2e file name test * test lint checks * testing fixes * moved tests to new file * fixed last test * file name, conciseness * additional tests * slight test changes * updated tests * fixing tests * refactored tests * lint * moved invalid stein tests to broken tests file * fixed test check * sep broken stein tests * renamed, removed prints * edited logs to now also show script file name * formatting * increase timeout * env vars method * printing env variables for failing tests * cls stop env * class methods * setting file name explicitly * reset script file name after test ends * lint + misc fixes * refactor tests * added extra tests * debugging logs * global var * added method to testutils * validate file name + tests * fixed tests * copyright --------- Co-authored-by: Varad Meru <vrdmr@users.noreply.github.com> Co-authored-by: gavin-aguiar <80794152+gavin-aguiar@users.noreply.github.com>
1 parent 16c109a commit 0b21dc0

File tree

17 files changed

+452
-28
lines changed

17 files changed

+452
-28
lines changed

azure_functions_worker/constants.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,13 @@
4141
PYTHON_ENABLE_WORKER_EXTENSIONS_DEFAULT_39 = True
4242
PYTHON_EXTENSIONS_RELOAD_FUNCTIONS = "PYTHON_EXTENSIONS_RELOAD_FUNCTIONS"
4343

44+
# new programming model default script file name
45+
PYTHON_SCRIPT_FILE_NAME = "PYTHON_SCRIPT_FILE_NAME"
46+
PYTHON_SCRIPT_FILE_NAME_DEFAULT = "function_app.py"
47+
4448
# External Site URLs
4549
MODULE_NOT_FOUND_TS_URL = "https://aka.ms/functions-modulenotfound"
4650

47-
# new programming model script file name
48-
SCRIPT_FILE_NAME = "function_app.py"
4951
PYTHON_LANGUAGE_RUNTIME = "python"
5052

5153
# Settings for V2 programming model

azure_functions_worker/dispatcher.py

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,16 @@
2828
PYTHON_THREADPOOL_THREAD_COUNT_MAX_37,
2929
PYTHON_THREADPOOL_THREAD_COUNT_MIN,
3030
PYTHON_ENABLE_DEBUG_LOGGING,
31-
SCRIPT_FILE_NAME,
31+
PYTHON_SCRIPT_FILE_NAME,
32+
PYTHON_SCRIPT_FILE_NAME_DEFAULT,
3233
PYTHON_LANGUAGE_RUNTIME, CUSTOMER_PACKAGES_PATH)
3334
from .extension import ExtensionManager
3435
from .logging import disable_console_logging, enable_console_logging
3536
from .logging import (logger, error_logger, is_system_log_category,
3637
CONSOLE_LOG_PREFIX, format_exception)
3738
from .utils.app_setting_manager import get_python_appsetting_state
38-
from .utils.common import get_app_setting, is_envvar_true
39+
from .utils.common import (get_app_setting, is_envvar_true,
40+
validate_script_file_name)
3941
from .utils.dependency import DependencyManager
4042
from .utils.tracing import marshall_exception_trace
4143
from .utils.wrappers import disable_feature_by
@@ -327,24 +329,29 @@ async def _handle__worker_status_request(self, request):
327329
async def _handle__functions_metadata_request(self, request):
328330
metadata_request = request.functions_metadata_request
329331
directory = metadata_request.function_app_directory
330-
function_path = os.path.join(directory, SCRIPT_FILE_NAME)
332+
script_file_name = get_app_setting(
333+
setting=PYTHON_SCRIPT_FILE_NAME,
334+
default_value=f'{PYTHON_SCRIPT_FILE_NAME_DEFAULT}')
335+
function_path = os.path.join(directory, script_file_name)
331336

332337
logger.info(
333-
'Received WorkerMetadataRequest, request ID %s, directory: %s',
334-
self.request_id, directory)
335-
336-
if not os.path.exists(function_path):
337-
# Fallback to legacy model
338-
logger.info("%s does not exist. "
339-
"Switching to host indexing.", SCRIPT_FILE_NAME)
340-
return protos.StreamingMessage(
341-
request_id=request.request_id,
342-
function_metadata_response=protos.FunctionMetadataResponse(
343-
use_default_metadata_indexing=True,
344-
result=protos.StatusResult(
345-
status=protos.StatusResult.Success)))
338+
'Received WorkerMetadataRequest, request ID %s, function_path: %s',
339+
self.request_id, function_path)
346340

347341
try:
342+
validate_script_file_name(script_file_name)
343+
344+
if not os.path.exists(function_path):
345+
# Fallback to legacy model
346+
logger.info("%s does not exist. "
347+
"Switching to host indexing.", script_file_name)
348+
return protos.StreamingMessage(
349+
request_id=request.request_id,
350+
function_metadata_response=protos.FunctionMetadataResponse(
351+
use_default_metadata_indexing=True,
352+
result=protos.StatusResult(
353+
status=protos.StatusResult.Success)))
354+
348355
fx_metadata_results = self.index_functions(function_path)
349356

350357
return protos.StreamingMessage(
@@ -367,8 +374,6 @@ async def _handle__function_load_request(self, request):
367374
function_id = func_request.function_id
368375
function_metadata = func_request.metadata
369376
function_name = function_metadata.name
370-
function_path = os.path.join(function_metadata.directory,
371-
SCRIPT_FILE_NAME)
372377

373378
logger.info(
374379
'Received WorkerLoadRequest, request ID %s, function_id: %s,'
@@ -377,6 +382,14 @@ async def _handle__function_load_request(self, request):
377382
programming_model = "V1"
378383
try:
379384
if not self._functions.get_function(function_id):
385+
script_file_name = get_app_setting(
386+
setting=PYTHON_SCRIPT_FILE_NAME,
387+
default_value=f'{PYTHON_SCRIPT_FILE_NAME_DEFAULT}')
388+
validate_script_file_name(script_file_name)
389+
function_path = os.path.join(
390+
function_metadata.directory,
391+
script_file_name)
392+
380393
if function_metadata.properties.get("worker_indexed", False) \
381394
or os.path.exists(function_path):
382395
# This is for the second worker and above where the worker

azure_functions_worker/loader.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@
1717

1818
from . import protos, functions
1919
from .bindings.retrycontext import RetryPolicy
20-
from .constants import MODULE_NOT_FOUND_TS_URL, SCRIPT_FILE_NAME, \
21-
PYTHON_LANGUAGE_RUNTIME, RETRY_POLICY, CUSTOMER_PACKAGES_PATH
20+
from .utils.common import get_app_setting
21+
from .constants import MODULE_NOT_FOUND_TS_URL, PYTHON_SCRIPT_FILE_NAME, \
22+
PYTHON_SCRIPT_FILE_NAME_DEFAULT, PYTHON_LANGUAGE_RUNTIME, \
23+
CUSTOMER_PACKAGES_PATH, RETRY_POLICY
2224
from .logging import logger
2325
from .utils.wrappers import attach_message_to_exception
2426

@@ -225,7 +227,10 @@ def index_function_app(function_path: str):
225227
f"level function app instances are defined.")
226228

227229
if not app:
230+
script_file_name = get_app_setting(
231+
setting=PYTHON_SCRIPT_FILE_NAME,
232+
default_value=f'{PYTHON_SCRIPT_FILE_NAME_DEFAULT}')
228233
raise ValueError("Could not find top level function app instances in "
229-
f"{SCRIPT_FILE_NAME}.")
234+
f"{script_file_name}.")
230235

231236
return app.get_functions()

azure_functions_worker/utils/app_setting_manager.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
PYTHON_ENABLE_WORKER_EXTENSIONS_DEFAULT,
1111
PYTHON_ENABLE_WORKER_EXTENSIONS_DEFAULT_39,
1212
PYTHON_ENABLE_DEBUG_LOGGING,
13-
FUNCTIONS_WORKER_SHARED_MEMORY_DATA_TRANSFER_ENABLED)
13+
FUNCTIONS_WORKER_SHARED_MEMORY_DATA_TRANSFER_ENABLED,
14+
PYTHON_SCRIPT_FILE_NAME)
1415

1516

1617
def get_python_appsetting_state():
@@ -21,7 +22,8 @@ def get_python_appsetting_state():
2122
PYTHON_ISOLATE_WORKER_DEPENDENCIES,
2223
PYTHON_ENABLE_DEBUG_LOGGING,
2324
PYTHON_ENABLE_WORKER_EXTENSIONS,
24-
FUNCTIONS_WORKER_SHARED_MEMORY_DATA_TRANSFER_ENABLED]
25+
FUNCTIONS_WORKER_SHARED_MEMORY_DATA_TRANSFER_ENABLED,
26+
PYTHON_SCRIPT_FILE_NAME]
2527

2628
app_setting_states = "".join(
2729
f"{app_setting}: {current_vars[app_setting]} | "

azure_functions_worker/utils/common.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import importlib
44
import os
55
import sys
6+
import re
67
from types import ModuleType
78
from typing import Optional, Callable
89

@@ -136,3 +137,20 @@ def get_sdk_from_sys_path() -> ModuleType:
136137
sys.path.insert(0, CUSTOMER_PACKAGES_PATH)
137138

138139
return importlib.import_module('azure.functions')
140+
141+
142+
class InvalidFileNameError(Exception):
143+
144+
def __init__(self, file_name: str) -> None:
145+
super().__init__(
146+
f'Invalid file name: {file_name}')
147+
148+
149+
def validate_script_file_name(file_name: str):
150+
# First character can be a letter, number, or underscore
151+
# Following characters can be a letter, number, underscore, hyphen, or dash
152+
# Ending must be .py
153+
pattern = re.compile(r'^[a-zA-Z0-9_][a-zA-Z0-9_\-]*\.py$')
154+
if not pattern.match(file_name):
155+
raise InvalidFileNameError(file_name)
156+
return True
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
from datetime import datetime
5+
import logging
6+
import time
7+
8+
import azure.functions as func
9+
10+
app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS)
11+
12+
13+
@app.route(route="default_template")
14+
def default_template(req: func.HttpRequest) -> func.HttpResponse:
15+
logging.info('Python HTTP trigger function processed a request.')
16+
17+
name = req.params.get('name')
18+
if not name:
19+
try:
20+
req_body = req.get_json()
21+
except ValueError:
22+
pass
23+
else:
24+
name = req_body.get('name')
25+
26+
if name:
27+
return func.HttpResponse(
28+
f"Hello, {name}. This HTTP triggered function "
29+
f"executed successfully.")
30+
else:
31+
return func.HttpResponse(
32+
"This HTTP triggered function executed successfully. "
33+
"Pass a name in the query string or in the request body for a"
34+
" personalized response.",
35+
status_code=200
36+
)
37+
38+
39+
@app.route(route="http_func")
40+
def http_func(req: func.HttpRequest) -> func.HttpResponse:
41+
time.sleep(1)
42+
43+
current_time = datetime.now().strftime("%H:%M:%S")
44+
return func.HttpResponse(f"{current_time}")
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
import os
4+
import requests
5+
6+
from azure_functions_worker.constants import PYTHON_SCRIPT_FILE_NAME
7+
from tests.utils import testutils
8+
9+
REQUEST_TIMEOUT_SEC = 10
10+
11+
12+
class TestHttpFunctionsFileName(testutils.WebHostTestCase):
13+
"""Test the native Http Trigger in the local webhost.
14+
15+
This test class will spawn a webhost from your <project_root>/build/webhost
16+
folder and replace the built-in Python with azure_functions_worker from
17+
your code base. Since the Http Trigger is a native suport from host, we
18+
don't need to setup any external resources.
19+
20+
Compared to the unittests/test_http_functions.py, this file is more focus
21+
on testing the E2E flow scenarios.
22+
"""
23+
@classmethod
24+
def get_script_name(cls):
25+
return "main.py"
26+
27+
@classmethod
28+
def get_script_dir(cls):
29+
return testutils.E2E_TESTS_FOLDER / 'http_functions' / \
30+
'http_functions_stein' / \
31+
'file_name'
32+
33+
@testutils.retryable_test(3, 5)
34+
def test_index_page_should_return_ok(self):
35+
"""The index page of Azure Functions should return OK in any
36+
circumstances
37+
"""
38+
r = self.webhost.request('GET', '', no_prefix=True,
39+
timeout=REQUEST_TIMEOUT_SEC)
40+
self.assertTrue(r.ok)
41+
42+
@testutils.retryable_test(3, 5)
43+
def test_default_http_template_should_return_ok(self):
44+
"""Test if the default template of Http trigger in Python Function app
45+
will return OK
46+
"""
47+
r = self.webhost.request('GET', 'default_template',
48+
timeout=REQUEST_TIMEOUT_SEC)
49+
self.assertTrue(r.ok)
50+
51+
@testutils.retryable_test(3, 5)
52+
def test_default_http_template_should_accept_query_param(self):
53+
"""Test if the azure.functions SDK is able to deserialize query
54+
parameter from the default template
55+
"""
56+
r = self.webhost.request('GET', 'default_template',
57+
params={'name': 'query'},
58+
timeout=REQUEST_TIMEOUT_SEC)
59+
self.assertTrue(r.ok)
60+
self.assertEqual(
61+
r.content,
62+
b'Hello, query. This HTTP triggered function executed successfully.'
63+
)
64+
65+
@testutils.retryable_test(3, 5)
66+
def test_default_http_template_should_accept_body(self):
67+
"""Test if the azure.functions SDK is able to deserialize http body
68+
and pass it to default template
69+
"""
70+
r = self.webhost.request('POST', 'default_template',
71+
data='{ "name": "body" }'.encode('utf-8'),
72+
timeout=REQUEST_TIMEOUT_SEC)
73+
self.assertTrue(r.ok)
74+
self.assertEqual(
75+
r.content,
76+
b'Hello, body. This HTTP triggered function executed successfully.'
77+
)
78+
79+
@testutils.retryable_test(3, 5)
80+
def test_worker_status_endpoint_should_return_ok(self):
81+
"""Test if the worker status endpoint will trigger
82+
_handle__worker_status_request and sends a worker status response back
83+
to host
84+
"""
85+
root_url = self.webhost._addr
86+
health_check_url = f'{root_url}/admin/host/ping'
87+
r = requests.post(health_check_url,
88+
params={'checkHealth': '1'},
89+
timeout=REQUEST_TIMEOUT_SEC)
90+
self.assertTrue(r.ok)
91+
92+
@testutils.retryable_test(3, 5)
93+
def test_worker_status_endpoint_should_return_ok_when_disabled(self):
94+
"""Test if the worker status endpoint will trigger
95+
_handle__worker_status_request and sends a worker status response back
96+
to host
97+
"""
98+
os.environ['WEBSITE_PING_METRICS_SCALE_ENABLED'] = '0'
99+
root_url = self.webhost._addr
100+
health_check_url = f'{root_url}/admin/host/ping'
101+
r = requests.post(health_check_url,
102+
params={'checkHealth': '1'},
103+
timeout=REQUEST_TIMEOUT_SEC)
104+
self.assertTrue(r.ok)
105+
106+
def test_correct_file_name(self):
107+
self.assertIsNotNone(os.environ.get(PYTHON_SCRIPT_FILE_NAME))
108+
self.assertEqual(os.environ.get(PYTHON_SCRIPT_FILE_NAME),
109+
'main.py')
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import azure.functions as func
2+
3+
4+
def main(req: func.HttpRequest):
5+
pass
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
import azure.functions as func
5+
6+
app = func.FunctionApp()
7+
8+
9+
@app.route(route="return_str")
10+
def return_str(req: func.HttpRequest) -> str:
11+
return 'Hello World!'
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
import azure.functions as func
5+
6+
app = func.FunctionApp()
7+
8+
9+
@app.route(route="return_str")
10+
def return_str(req: func.HttpRequest) -> str:
11+
return 'Hello World!'

0 commit comments

Comments
 (0)