functions-framework 3.6.0__tar.gz → 3.8.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {functions_framework-3.6.0 → functions_framework-3.8.0}/PKG-INFO +2 -1
- {functions_framework-3.6.0 → functions_framework-3.8.0}/setup.py +2 -1
- {functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework/__init__.py +39 -1
- functions_framework-3.8.0/src/functions_framework/_http/gunicorn.py +72 -0
- {functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework/exceptions.py +5 -1
- functions_framework-3.8.0/src/functions_framework/execution_id.py +156 -0
- functions_framework-3.8.0/src/functions_framework/request_timeout.py +42 -0
- {functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework.egg-info/PKG-INFO +2 -1
- {functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework.egg-info/SOURCES.txt +4 -0
- {functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework.egg-info/requires.txt +1 -0
- functions_framework-3.8.0/tests/test_execution_id.py +382 -0
- {functions_framework-3.6.0 → functions_framework-3.8.0}/tests/test_http.py +6 -6
- functions_framework-3.8.0/tests/test_timeouts.py +265 -0
- {functions_framework-3.6.0 → functions_framework-3.8.0}/tests/test_view_functions.py +1 -1
- functions_framework-3.6.0/src/functions_framework/_http/gunicorn.py +0 -40
- {functions_framework-3.6.0 → functions_framework-3.8.0}/LICENSE +0 -0
- {functions_framework-3.6.0 → functions_framework-3.8.0}/README.md +0 -0
- {functions_framework-3.6.0 → functions_framework-3.8.0}/setup.cfg +0 -0
- {functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework/__main__.py +0 -0
- {functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework/_cli.py +0 -0
- {functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework/_function_registry.py +0 -0
- {functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework/_http/__init__.py +0 -0
- {functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework/_http/flask.py +0 -0
- {functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework/_typed_event.py +0 -0
- {functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework/background_event.py +0 -0
- {functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework/event_conversion.py +0 -0
- {functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework/py.typed +0 -0
- {functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework.egg-info/dependency_links.txt +0 -0
- {functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework.egg-info/entry_points.txt +0 -0
- {functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework.egg-info/namespace_packages.txt +0 -0
- {functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework.egg-info/top_level.txt +0 -0
- {functions_framework-3.6.0 → functions_framework-3.8.0}/src/google/__init__.py +0 -0
- {functions_framework-3.6.0 → functions_framework-3.8.0}/src/google/cloud/__init__.py +0 -0
- {functions_framework-3.6.0 → functions_framework-3.8.0}/src/google/cloud/functions/__init__.py +0 -0
- {functions_framework-3.6.0 → functions_framework-3.8.0}/src/google/cloud/functions/context.py +0 -0
- {functions_framework-3.6.0 → functions_framework-3.8.0}/src/google/cloud/functions_v1/__init__.py +0 -0
- {functions_framework-3.6.0 → functions_framework-3.8.0}/src/google/cloud/functions_v1/context.py +0 -0
- {functions_framework-3.6.0 → functions_framework-3.8.0}/src/google/cloud/functions_v1beta2/__init__.py +0 -0
- {functions_framework-3.6.0 → functions_framework-3.8.0}/src/google/cloud/functions_v1beta2/context.py +0 -0
- {functions_framework-3.6.0 → functions_framework-3.8.0}/tests/test_cli.py +0 -0
- {functions_framework-3.6.0 → functions_framework-3.8.0}/tests/test_cloud_event_functions.py +0 -0
- {functions_framework-3.6.0 → functions_framework-3.8.0}/tests/test_convert.py +0 -0
- {functions_framework-3.6.0 → functions_framework-3.8.0}/tests/test_decorator_functions.py +0 -0
- {functions_framework-3.6.0 → functions_framework-3.8.0}/tests/test_function_registry.py +0 -0
- {functions_framework-3.6.0 → functions_framework-3.8.0}/tests/test_functions.py +0 -0
- {functions_framework-3.6.0 → functions_framework-3.8.0}/tests/test_main.py +0 -0
- {functions_framework-3.6.0 → functions_framework-3.8.0}/tests/test_samples.py +0 -0
- {functions_framework-3.6.0 → functions_framework-3.8.0}/tests/test_typed_event_functions.py +0 -0
- {functions_framework-3.6.0 → functions_framework-3.8.0}/tests/test_typing.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: functions-framework
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.8.0
|
|
4
4
|
Summary: An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team.
|
|
5
5
|
Home-page: https://github.com/googlecloudplatform/functions-framework-python
|
|
6
6
|
Author: Google LLC
|
|
@@ -23,6 +23,7 @@ Requires-Dist: click<9.0,>=7.0
|
|
|
23
23
|
Requires-Dist: watchdog>=1.0.0
|
|
24
24
|
Requires-Dist: gunicorn>=19.2.0; platform_system != "Windows"
|
|
25
25
|
Requires-Dist: cloudevents<2.0.0,>=1.2.0
|
|
26
|
+
Requires-Dist: Werkzeug<4.0.0,>=0.14
|
|
26
27
|
|
|
27
28
|
# Functions Framework for Python
|
|
28
29
|
|
|
@@ -25,7 +25,7 @@ with open(path.join(here, "README.md"), encoding="utf-8") as f:
|
|
|
25
25
|
|
|
26
26
|
setup(
|
|
27
27
|
name="functions-framework",
|
|
28
|
-
version="3.
|
|
28
|
+
version="3.8.0",
|
|
29
29
|
description="An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team.",
|
|
30
30
|
long_description=long_description,
|
|
31
31
|
long_description_content_type="text/markdown",
|
|
@@ -55,6 +55,7 @@ setup(
|
|
|
55
55
|
"watchdog>=1.0.0",
|
|
56
56
|
"gunicorn>=19.2.0; platform_system!='Windows'",
|
|
57
57
|
"cloudevents>=1.2.0,<2.0.0",
|
|
58
|
+
"Werkzeug>=0.14,<4.0.0",
|
|
58
59
|
],
|
|
59
60
|
entry_points={
|
|
60
61
|
"console_scripts": [
|
|
@@ -17,6 +17,8 @@ import inspect
|
|
|
17
17
|
import io
|
|
18
18
|
import json
|
|
19
19
|
import logging
|
|
20
|
+
import logging.config
|
|
21
|
+
import os
|
|
20
22
|
import os.path
|
|
21
23
|
import pathlib
|
|
22
24
|
import sys
|
|
@@ -32,7 +34,12 @@ import werkzeug
|
|
|
32
34
|
from cloudevents.http import from_http, is_binary
|
|
33
35
|
from cloudevents.http.event import CloudEvent
|
|
34
36
|
|
|
35
|
-
from functions_framework import
|
|
37
|
+
from functions_framework import (
|
|
38
|
+
_function_registry,
|
|
39
|
+
_typed_event,
|
|
40
|
+
event_conversion,
|
|
41
|
+
execution_id,
|
|
42
|
+
)
|
|
36
43
|
from functions_framework.background_event import BackgroundEvent
|
|
37
44
|
from functions_framework.exceptions import (
|
|
38
45
|
EventConversionException,
|
|
@@ -129,6 +136,7 @@ def setup_logging():
|
|
|
129
136
|
|
|
130
137
|
|
|
131
138
|
def _http_view_func_wrapper(function, request):
|
|
139
|
+
@execution_id.set_execution_context(request, _enable_execution_id_logging())
|
|
132
140
|
@functools.wraps(function)
|
|
133
141
|
def view_func(path):
|
|
134
142
|
return function(request._get_current_object())
|
|
@@ -143,6 +151,7 @@ def _run_cloud_event(function, request):
|
|
|
143
151
|
|
|
144
152
|
|
|
145
153
|
def _typed_event_func_wrapper(function, request, inputType: Type):
|
|
154
|
+
@execution_id.set_execution_context(request, _enable_execution_id_logging())
|
|
146
155
|
def view_func(path):
|
|
147
156
|
try:
|
|
148
157
|
data = request.get_json()
|
|
@@ -163,6 +172,7 @@ def _typed_event_func_wrapper(function, request, inputType: Type):
|
|
|
163
172
|
|
|
164
173
|
|
|
165
174
|
def _cloud_event_view_func_wrapper(function, request):
|
|
175
|
+
@execution_id.set_execution_context(request, _enable_execution_id_logging())
|
|
166
176
|
def view_func(path):
|
|
167
177
|
ce_exception = None
|
|
168
178
|
event = None
|
|
@@ -198,6 +208,7 @@ def _cloud_event_view_func_wrapper(function, request):
|
|
|
198
208
|
|
|
199
209
|
|
|
200
210
|
def _event_view_func_wrapper(function, request):
|
|
211
|
+
@execution_id.set_execution_context(request, _enable_execution_id_logging())
|
|
201
212
|
def view_func(path):
|
|
202
213
|
if event_conversion.is_convertable_cloud_event(request):
|
|
203
214
|
# Convert this CloudEvent to the equivalent background event data and context.
|
|
@@ -332,6 +343,9 @@ def create_app(target=None, source=None, signature_type=None):
|
|
|
332
343
|
|
|
333
344
|
source_module, spec = _function_registry.load_function_module(source)
|
|
334
345
|
|
|
346
|
+
if _enable_execution_id_logging():
|
|
347
|
+
_configure_app_execution_id_logging()
|
|
348
|
+
|
|
335
349
|
# Create the application
|
|
336
350
|
_app = flask.Flask(target, template_folder=template_folder)
|
|
337
351
|
_app.register_error_handler(500, crash_handler)
|
|
@@ -355,6 +369,7 @@ def create_app(target=None, source=None, signature_type=None):
|
|
|
355
369
|
sys.stderr = _LoggingHandler("ERROR", sys.stderr)
|
|
356
370
|
setup_logging()
|
|
357
371
|
|
|
372
|
+
_app.wsgi_app = execution_id.WsgiMiddleware(_app.wsgi_app)
|
|
358
373
|
# Execute the module, within the application context
|
|
359
374
|
with _app.app_context():
|
|
360
375
|
try:
|
|
@@ -411,6 +426,29 @@ class LazyWSGIApp:
|
|
|
411
426
|
return self.app(*args, **kwargs)
|
|
412
427
|
|
|
413
428
|
|
|
429
|
+
def _configure_app_execution_id_logging():
|
|
430
|
+
# Logging needs to be configured before app logger is accessed
|
|
431
|
+
logging.config.dictConfig(
|
|
432
|
+
{
|
|
433
|
+
"version": 1,
|
|
434
|
+
"handlers": {
|
|
435
|
+
"wsgi": {
|
|
436
|
+
"class": "logging.StreamHandler",
|
|
437
|
+
"stream": "ext://functions_framework.execution_id.logging_stream",
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
"root": {"level": "WARNING", "handlers": ["wsgi"]},
|
|
441
|
+
}
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _enable_execution_id_logging():
|
|
446
|
+
# Based on distutils.util.strtobool
|
|
447
|
+
truthy_values = ("y", "yes", "t", "true", "on", "1")
|
|
448
|
+
env_var_value = os.environ.get("LOG_EXECUTION_ID")
|
|
449
|
+
return env_var_value in truthy_values
|
|
450
|
+
|
|
451
|
+
|
|
414
452
|
app = LazyWSGIApp()
|
|
415
453
|
|
|
416
454
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Copyright 2024 Google LLC
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
|
|
17
|
+
import gunicorn.app.base
|
|
18
|
+
|
|
19
|
+
from gunicorn.workers.gthread import ThreadWorker
|
|
20
|
+
|
|
21
|
+
from ..request_timeout import ThreadingTimeout
|
|
22
|
+
|
|
23
|
+
# global for use in our custom gthread worker; the gunicorn arbiter spawns these
|
|
24
|
+
# and it's not possible to inject (and self.timeout means something different to
|
|
25
|
+
# async workers!)
|
|
26
|
+
# set/managed in gunicorn application init for test-friendliness
|
|
27
|
+
TIMEOUT_SECONDS = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class GunicornApplication(gunicorn.app.base.BaseApplication):
|
|
31
|
+
def __init__(self, app, host, port, debug, **options):
|
|
32
|
+
threads = int(os.environ.get("THREADS", (os.cpu_count() or 1) * 4))
|
|
33
|
+
|
|
34
|
+
global TIMEOUT_SECONDS
|
|
35
|
+
TIMEOUT_SECONDS = int(os.environ.get("CLOUD_RUN_TIMEOUT_SECONDS", 0))
|
|
36
|
+
|
|
37
|
+
self.options = {
|
|
38
|
+
"bind": "%s:%s" % (host, port),
|
|
39
|
+
"workers": int(os.environ.get("WORKERS", 1)),
|
|
40
|
+
"threads": threads,
|
|
41
|
+
"loglevel": os.environ.get("GUNICORN_LOG_LEVEL", "error"),
|
|
42
|
+
"limit_request_line": 0,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (
|
|
46
|
+
TIMEOUT_SECONDS > 0
|
|
47
|
+
and threads > 1
|
|
48
|
+
and (os.environ.get("THREADED_TIMEOUT_ENABLED", "False").lower() == "true")
|
|
49
|
+
): # pragma: no cover
|
|
50
|
+
self.options["worker_class"] = (
|
|
51
|
+
"functions_framework._http.gunicorn.GThreadWorkerWithTimeoutSupport"
|
|
52
|
+
)
|
|
53
|
+
else:
|
|
54
|
+
self.options["timeout"] = TIMEOUT_SECONDS
|
|
55
|
+
|
|
56
|
+
self.options.update(options)
|
|
57
|
+
self.app = app
|
|
58
|
+
|
|
59
|
+
super().__init__()
|
|
60
|
+
|
|
61
|
+
def load_config(self):
|
|
62
|
+
for key, value in self.options.items():
|
|
63
|
+
self.cfg.set(key, value)
|
|
64
|
+
|
|
65
|
+
def load(self):
|
|
66
|
+
return self.app
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class GThreadWorkerWithTimeoutSupport(ThreadWorker): # pragma: no cover
|
|
70
|
+
def handle_request(self, req, conn):
|
|
71
|
+
with ThreadingTimeout(TIMEOUT_SECONDS):
|
|
72
|
+
super(GThreadWorkerWithTimeoutSupport, self).handle_request(req, conn)
|
{functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework/exceptions.py
RENAMED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Copyright
|
|
1
|
+
# Copyright 2024 Google LLC
|
|
2
2
|
#
|
|
3
3
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
4
|
# you may not use this file except in compliance with the License.
|
|
@@ -35,3 +35,7 @@ class MissingTargetException(FunctionsFrameworkException):
|
|
|
35
35
|
|
|
36
36
|
class EventConversionException(FunctionsFrameworkException):
|
|
37
37
|
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class RequestTimeoutException(FunctionsFrameworkException):
|
|
41
|
+
pass
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# Copyright 2020 Google LLC
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
import contextlib
|
|
16
|
+
import functools
|
|
17
|
+
import io
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import random
|
|
21
|
+
import re
|
|
22
|
+
import string
|
|
23
|
+
import sys
|
|
24
|
+
|
|
25
|
+
import flask
|
|
26
|
+
|
|
27
|
+
from werkzeug.local import LocalProxy
|
|
28
|
+
|
|
29
|
+
_EXECUTION_ID_LENGTH = 12
|
|
30
|
+
_EXECUTION_ID_CHARSET = string.digits + string.ascii_letters
|
|
31
|
+
_LOGGING_API_LABELS_FIELD = "logging.googleapis.com/labels"
|
|
32
|
+
_LOGGING_API_SPAN_ID_FIELD = "logging.googleapis.com/spanId"
|
|
33
|
+
_TRACE_CONTEXT_REGEX_PATTERN = re.compile(
|
|
34
|
+
r"^(?P<trace_id>[\w\d]+)/(?P<span_id>\d+);o=(?P<options>[01])$"
|
|
35
|
+
)
|
|
36
|
+
EXECUTION_ID_REQUEST_HEADER = "Function-Execution-Id"
|
|
37
|
+
TRACE_CONTEXT_REQUEST_HEADER = "X-Cloud-Trace-Context"
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ExecutionContext:
|
|
43
|
+
def __init__(self, execution_id=None, span_id=None):
|
|
44
|
+
self.execution_id = execution_id
|
|
45
|
+
self.span_id = span_id
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _get_current_context():
|
|
49
|
+
return (
|
|
50
|
+
flask.g.execution_id_context
|
|
51
|
+
if flask.has_request_context() and "execution_id_context" in flask.g
|
|
52
|
+
else None
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _set_current_context(context):
|
|
57
|
+
if flask.has_request_context():
|
|
58
|
+
flask.g.execution_id_context = context
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _generate_execution_id():
|
|
62
|
+
return "".join(
|
|
63
|
+
_EXECUTION_ID_CHARSET[random.randrange(len(_EXECUTION_ID_CHARSET))]
|
|
64
|
+
for _ in range(_EXECUTION_ID_LENGTH)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# Middleware to add execution id to request header if one does not already exist
|
|
69
|
+
class WsgiMiddleware:
|
|
70
|
+
def __init__(self, wsgi_app):
|
|
71
|
+
self.wsgi_app = wsgi_app
|
|
72
|
+
|
|
73
|
+
def __call__(self, environ, start_response):
|
|
74
|
+
execution_id = (
|
|
75
|
+
environ.get("HTTP_FUNCTION_EXECUTION_ID") or _generate_execution_id()
|
|
76
|
+
)
|
|
77
|
+
environ["HTTP_FUNCTION_EXECUTION_ID"] = execution_id
|
|
78
|
+
return self.wsgi_app(environ, start_response)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# Sets execution id and span id for the request
|
|
82
|
+
def set_execution_context(request, enable_id_logging=False):
|
|
83
|
+
if enable_id_logging:
|
|
84
|
+
stdout_redirect = contextlib.redirect_stdout(
|
|
85
|
+
LoggingHandlerAddExecutionId(sys.stdout)
|
|
86
|
+
)
|
|
87
|
+
stderr_redirect = contextlib.redirect_stderr(
|
|
88
|
+
LoggingHandlerAddExecutionId(sys.stderr)
|
|
89
|
+
)
|
|
90
|
+
else:
|
|
91
|
+
stdout_redirect = contextlib.nullcontext()
|
|
92
|
+
stderr_redirect = contextlib.nullcontext()
|
|
93
|
+
|
|
94
|
+
def decorator(view_function):
|
|
95
|
+
@functools.wraps(view_function)
|
|
96
|
+
def wrapper(*args, **kwargs):
|
|
97
|
+
trace_context = re.match(
|
|
98
|
+
_TRACE_CONTEXT_REGEX_PATTERN,
|
|
99
|
+
request.headers.get(TRACE_CONTEXT_REQUEST_HEADER, ""),
|
|
100
|
+
)
|
|
101
|
+
execution_id = request.headers.get(EXECUTION_ID_REQUEST_HEADER)
|
|
102
|
+
span_id = trace_context.group("span_id") if trace_context else None
|
|
103
|
+
_set_current_context(ExecutionContext(execution_id, span_id))
|
|
104
|
+
|
|
105
|
+
with stderr_redirect, stdout_redirect:
|
|
106
|
+
return view_function(*args, **kwargs)
|
|
107
|
+
|
|
108
|
+
return wrapper
|
|
109
|
+
|
|
110
|
+
return decorator
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@LocalProxy
|
|
114
|
+
def logging_stream():
|
|
115
|
+
return LoggingHandlerAddExecutionId(stream=flask.logging.wsgi_errors_stream)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class LoggingHandlerAddExecutionId(io.TextIOWrapper):
|
|
119
|
+
def __new__(cls, stream=sys.stdout):
|
|
120
|
+
if isinstance(stream, LoggingHandlerAddExecutionId):
|
|
121
|
+
return stream
|
|
122
|
+
else:
|
|
123
|
+
return super(LoggingHandlerAddExecutionId, cls).__new__(cls)
|
|
124
|
+
|
|
125
|
+
def __init__(self, stream=sys.stdout):
|
|
126
|
+
io.TextIOWrapper.__init__(self, io.StringIO())
|
|
127
|
+
self.stream = stream
|
|
128
|
+
|
|
129
|
+
def write(self, contents):
|
|
130
|
+
if contents == "\n":
|
|
131
|
+
return
|
|
132
|
+
current_context = _get_current_context()
|
|
133
|
+
if current_context is None:
|
|
134
|
+
self.stream.write(contents + "\n")
|
|
135
|
+
self.stream.flush()
|
|
136
|
+
return
|
|
137
|
+
try:
|
|
138
|
+
execution_id = current_context.execution_id
|
|
139
|
+
span_id = current_context.span_id
|
|
140
|
+
payload = json.loads(contents)
|
|
141
|
+
if not isinstance(payload, dict):
|
|
142
|
+
payload = {"message": contents}
|
|
143
|
+
except json.JSONDecodeError:
|
|
144
|
+
if len(contents) > 0 and contents[-1] == "\n":
|
|
145
|
+
contents = contents[:-1]
|
|
146
|
+
payload = {"message": contents}
|
|
147
|
+
if execution_id:
|
|
148
|
+
payload[_LOGGING_API_LABELS_FIELD] = payload.get(
|
|
149
|
+
_LOGGING_API_LABELS_FIELD, {}
|
|
150
|
+
)
|
|
151
|
+
payload[_LOGGING_API_LABELS_FIELD]["execution_id"] = execution_id
|
|
152
|
+
if span_id:
|
|
153
|
+
payload[_LOGGING_API_SPAN_ID_FIELD] = span_id
|
|
154
|
+
self.stream.write(json.dumps(payload))
|
|
155
|
+
self.stream.write("\n")
|
|
156
|
+
self.stream.flush()
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import ctypes
|
|
2
|
+
import logging
|
|
3
|
+
import threading
|
|
4
|
+
|
|
5
|
+
from .exceptions import RequestTimeoutException
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ThreadingTimeout(object): # pragma: no cover
|
|
11
|
+
def __init__(self, seconds):
|
|
12
|
+
self.seconds = seconds
|
|
13
|
+
self.target_tid = threading.current_thread().ident
|
|
14
|
+
self.timer = None
|
|
15
|
+
|
|
16
|
+
def __enter__(self):
|
|
17
|
+
self.timer = threading.Timer(self.seconds, self._raise_exc)
|
|
18
|
+
self.timer.start()
|
|
19
|
+
return self
|
|
20
|
+
|
|
21
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
22
|
+
self.timer.cancel()
|
|
23
|
+
if exc_type is RequestTimeoutException:
|
|
24
|
+
logger.warning(
|
|
25
|
+
"Request handling exceeded {0} seconds timeout; terminating request handling...".format(
|
|
26
|
+
self.seconds
|
|
27
|
+
),
|
|
28
|
+
exc_info=(exc_type, exc_val, exc_tb),
|
|
29
|
+
)
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
def _raise_exc(self):
|
|
33
|
+
ret = ctypes.pythonapi.PyThreadState_SetAsyncExc(
|
|
34
|
+
ctypes.c_long(self.target_tid), ctypes.py_object(RequestTimeoutException)
|
|
35
|
+
)
|
|
36
|
+
if ret == 0:
|
|
37
|
+
raise ValueError("Invalid thread ID {}".format(self.target_tid))
|
|
38
|
+
elif ret > 1:
|
|
39
|
+
ctypes.pythonapi.PyThreadState_SetAsyncExc(
|
|
40
|
+
ctypes.c_long(self.target_tid), None
|
|
41
|
+
)
|
|
42
|
+
raise SystemError("PyThreadState_SetAsyncExc failed")
|
{functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework.egg-info/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: functions-framework
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.8.0
|
|
4
4
|
Summary: An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team.
|
|
5
5
|
Home-page: https://github.com/googlecloudplatform/functions-framework-python
|
|
6
6
|
Author: Google LLC
|
|
@@ -23,6 +23,7 @@ Requires-Dist: click<9.0,>=7.0
|
|
|
23
23
|
Requires-Dist: watchdog>=1.0.0
|
|
24
24
|
Requires-Dist: gunicorn>=19.2.0; platform_system != "Windows"
|
|
25
25
|
Requires-Dist: cloudevents<2.0.0,>=1.2.0
|
|
26
|
+
Requires-Dist: Werkzeug<4.0.0,>=0.14
|
|
26
27
|
|
|
27
28
|
# Functions Framework for Python
|
|
28
29
|
|
{functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework.egg-info/SOURCES.txt
RENAMED
|
@@ -10,7 +10,9 @@ src/functions_framework/_typed_event.py
|
|
|
10
10
|
src/functions_framework/background_event.py
|
|
11
11
|
src/functions_framework/event_conversion.py
|
|
12
12
|
src/functions_framework/exceptions.py
|
|
13
|
+
src/functions_framework/execution_id.py
|
|
13
14
|
src/functions_framework/py.typed
|
|
15
|
+
src/functions_framework/request_timeout.py
|
|
14
16
|
src/functions_framework.egg-info/PKG-INFO
|
|
15
17
|
src/functions_framework.egg-info/SOURCES.txt
|
|
16
18
|
src/functions_framework.egg-info/dependency_links.txt
|
|
@@ -33,11 +35,13 @@ tests/test_cli.py
|
|
|
33
35
|
tests/test_cloud_event_functions.py
|
|
34
36
|
tests/test_convert.py
|
|
35
37
|
tests/test_decorator_functions.py
|
|
38
|
+
tests/test_execution_id.py
|
|
36
39
|
tests/test_function_registry.py
|
|
37
40
|
tests/test_functions.py
|
|
38
41
|
tests/test_http.py
|
|
39
42
|
tests/test_main.py
|
|
40
43
|
tests/test_samples.py
|
|
44
|
+
tests/test_timeouts.py
|
|
41
45
|
tests/test_typed_event_functions.py
|
|
42
46
|
tests/test_typing.py
|
|
43
47
|
tests/test_view_functions.py
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
# Copyright 2024 Google LLC
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
import pathlib
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
from functools import partial
|
|
21
|
+
from unittest.mock import Mock
|
|
22
|
+
|
|
23
|
+
import pretend
|
|
24
|
+
import pytest
|
|
25
|
+
|
|
26
|
+
from functions_framework import create_app, execution_id
|
|
27
|
+
|
|
28
|
+
TEST_FUNCTIONS_DIR = pathlib.Path(__file__).resolve().parent / "test_functions"
|
|
29
|
+
TEST_EXECUTION_ID = "test_execution_id"
|
|
30
|
+
TEST_SPAN_ID = "123456"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_user_function_can_retrieve_execution_id_from_header():
|
|
34
|
+
source = TEST_FUNCTIONS_DIR / "execution_id" / "main.py"
|
|
35
|
+
target = "function"
|
|
36
|
+
client = create_app(target, source).test_client()
|
|
37
|
+
resp = client.post(
|
|
38
|
+
"/",
|
|
39
|
+
headers={
|
|
40
|
+
"Function-Execution-Id": TEST_EXECUTION_ID,
|
|
41
|
+
"Content-Type": "application/json",
|
|
42
|
+
},
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
assert resp.get_json()["execution_id"] == TEST_EXECUTION_ID
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_uncaught_exception_in_user_function_sets_execution_id(capsys, monkeypatch):
|
|
49
|
+
monkeypatch.setenv("LOG_EXECUTION_ID", "true")
|
|
50
|
+
source = TEST_FUNCTIONS_DIR / "execution_id" / "main.py"
|
|
51
|
+
target = "error"
|
|
52
|
+
app = create_app(target, source)
|
|
53
|
+
client = app.test_client()
|
|
54
|
+
resp = client.post(
|
|
55
|
+
"/",
|
|
56
|
+
headers={
|
|
57
|
+
"Function-Execution-Id": TEST_EXECUTION_ID,
|
|
58
|
+
"Content-Type": "application/json",
|
|
59
|
+
},
|
|
60
|
+
)
|
|
61
|
+
assert resp.status_code == 500
|
|
62
|
+
record = capsys.readouterr()
|
|
63
|
+
assert f'"execution_id": "{TEST_EXECUTION_ID}"' in record.err
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_print_from_user_function_sets_execution_id(capsys, monkeypatch):
|
|
67
|
+
monkeypatch.setenv("LOG_EXECUTION_ID", "true")
|
|
68
|
+
source = TEST_FUNCTIONS_DIR / "execution_id" / "main.py"
|
|
69
|
+
target = "print_message"
|
|
70
|
+
app = create_app(target, source)
|
|
71
|
+
client = app.test_client()
|
|
72
|
+
client.post(
|
|
73
|
+
"/",
|
|
74
|
+
headers={
|
|
75
|
+
"Function-Execution-Id": TEST_EXECUTION_ID,
|
|
76
|
+
"Content-Type": "application/json",
|
|
77
|
+
},
|
|
78
|
+
json={"message": "some-message"},
|
|
79
|
+
)
|
|
80
|
+
record = capsys.readouterr()
|
|
81
|
+
assert f'"execution_id": "{TEST_EXECUTION_ID}"' in record.out
|
|
82
|
+
assert '"message": "some-message"' in record.out
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_log_from_user_function_sets_execution_id(capsys, monkeypatch):
|
|
86
|
+
monkeypatch.setenv("LOG_EXECUTION_ID", "true")
|
|
87
|
+
source = TEST_FUNCTIONS_DIR / "execution_id" / "main.py"
|
|
88
|
+
target = "log_message"
|
|
89
|
+
app = create_app(target, source)
|
|
90
|
+
client = app.test_client()
|
|
91
|
+
client.post(
|
|
92
|
+
"/",
|
|
93
|
+
headers={
|
|
94
|
+
"Function-Execution-Id": TEST_EXECUTION_ID,
|
|
95
|
+
"Content-Type": "application/json",
|
|
96
|
+
},
|
|
97
|
+
json={"message": json.dumps({"custom-field": "some-message"})},
|
|
98
|
+
)
|
|
99
|
+
record = capsys.readouterr()
|
|
100
|
+
assert f'"execution_id": "{TEST_EXECUTION_ID}"' in record.err
|
|
101
|
+
assert '"custom-field": "some-message"' in record.err
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_user_function_can_retrieve_generated_execution_id(monkeypatch):
|
|
105
|
+
monkeypatch.setattr(
|
|
106
|
+
execution_id, "_generate_execution_id", lambda: TEST_EXECUTION_ID
|
|
107
|
+
)
|
|
108
|
+
source = TEST_FUNCTIONS_DIR / "execution_id" / "main.py"
|
|
109
|
+
target = "function"
|
|
110
|
+
client = create_app(target, source).test_client()
|
|
111
|
+
resp = client.post(
|
|
112
|
+
"/",
|
|
113
|
+
headers={
|
|
114
|
+
"Content-Type": "application/json",
|
|
115
|
+
},
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
assert resp.get_json()["execution_id"] == TEST_EXECUTION_ID
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_does_not_set_execution_id_when_not_enabled(capsys):
|
|
122
|
+
source = TEST_FUNCTIONS_DIR / "execution_id" / "main.py"
|
|
123
|
+
target = "print_message"
|
|
124
|
+
app = create_app(target, source)
|
|
125
|
+
client = app.test_client()
|
|
126
|
+
client.post(
|
|
127
|
+
"/",
|
|
128
|
+
headers={
|
|
129
|
+
"Function-Execution-Id": TEST_EXECUTION_ID,
|
|
130
|
+
"Content-Type": "application/json",
|
|
131
|
+
},
|
|
132
|
+
json={"message": "some-message"},
|
|
133
|
+
)
|
|
134
|
+
record = capsys.readouterr()
|
|
135
|
+
assert f'"execution_id": "{TEST_EXECUTION_ID}"' not in record.out
|
|
136
|
+
assert "some-message" in record.out
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_does_not_set_execution_id_when_env_var_is_false(capsys, monkeypatch):
|
|
140
|
+
monkeypatch.setenv("LOG_EXECUTION_ID", "false")
|
|
141
|
+
source = TEST_FUNCTIONS_DIR / "execution_id" / "main.py"
|
|
142
|
+
target = "print_message"
|
|
143
|
+
app = create_app(target, source)
|
|
144
|
+
client = app.test_client()
|
|
145
|
+
client.post(
|
|
146
|
+
"/",
|
|
147
|
+
headers={
|
|
148
|
+
"Function-Execution-Id": TEST_EXECUTION_ID,
|
|
149
|
+
"Content-Type": "application/json",
|
|
150
|
+
},
|
|
151
|
+
json={"message": "some-message"},
|
|
152
|
+
)
|
|
153
|
+
record = capsys.readouterr()
|
|
154
|
+
assert f'"execution_id": "{TEST_EXECUTION_ID}"' not in record.out
|
|
155
|
+
assert "some-message" in record.out
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_does_not_set_execution_id_when_env_var_is_not_bool_like(capsys, monkeypatch):
|
|
159
|
+
monkeypatch.setenv("LOG_EXECUTION_ID", "maybe")
|
|
160
|
+
source = TEST_FUNCTIONS_DIR / "execution_id" / "main.py"
|
|
161
|
+
target = "print_message"
|
|
162
|
+
app = create_app(target, source)
|
|
163
|
+
client = app.test_client()
|
|
164
|
+
client.post(
|
|
165
|
+
"/",
|
|
166
|
+
headers={
|
|
167
|
+
"Function-Execution-Id": TEST_EXECUTION_ID,
|
|
168
|
+
"Content-Type": "application/json",
|
|
169
|
+
},
|
|
170
|
+
json={"message": "some-message"},
|
|
171
|
+
)
|
|
172
|
+
record = capsys.readouterr()
|
|
173
|
+
assert f'"execution_id": "{TEST_EXECUTION_ID}"' not in record.out
|
|
174
|
+
assert "some-message" in record.out
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_generate_execution_id():
|
|
178
|
+
expected_matching_regex = "^[0-9a-zA-Z]{12}$"
|
|
179
|
+
actual_execution_id = execution_id._generate_execution_id()
|
|
180
|
+
|
|
181
|
+
match = re.match(expected_matching_regex, actual_execution_id).group(0)
|
|
182
|
+
assert match == actual_execution_id
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@pytest.mark.parametrize(
|
|
186
|
+
"headers,expected_execution_id,expected_span_id",
|
|
187
|
+
[
|
|
188
|
+
(
|
|
189
|
+
{
|
|
190
|
+
"X-Cloud-Trace-Context": f"TRACE_ID/{TEST_SPAN_ID};o=1",
|
|
191
|
+
"Function-Execution-Id": TEST_EXECUTION_ID,
|
|
192
|
+
},
|
|
193
|
+
TEST_EXECUTION_ID,
|
|
194
|
+
TEST_SPAN_ID,
|
|
195
|
+
),
|
|
196
|
+
(
|
|
197
|
+
{
|
|
198
|
+
"X-Cloud-Trace-Context": f"TRACE_ID/{TEST_SPAN_ID};o=1",
|
|
199
|
+
"Function-Execution-Id": TEST_EXECUTION_ID,
|
|
200
|
+
},
|
|
201
|
+
TEST_EXECUTION_ID,
|
|
202
|
+
TEST_SPAN_ID,
|
|
203
|
+
),
|
|
204
|
+
({}, None, None),
|
|
205
|
+
(
|
|
206
|
+
{
|
|
207
|
+
"X-Cloud-Trace-Context": "malformed trace context string",
|
|
208
|
+
"Function-Execution-Id": TEST_EXECUTION_ID,
|
|
209
|
+
},
|
|
210
|
+
TEST_EXECUTION_ID,
|
|
211
|
+
None,
|
|
212
|
+
),
|
|
213
|
+
],
|
|
214
|
+
)
|
|
215
|
+
def test_set_execution_context(
|
|
216
|
+
headers, expected_execution_id, expected_span_id, monkeypatch
|
|
217
|
+
):
|
|
218
|
+
request = pretend.stub(headers=headers)
|
|
219
|
+
|
|
220
|
+
def view_func():
|
|
221
|
+
pass
|
|
222
|
+
|
|
223
|
+
monkeypatch.setattr(
|
|
224
|
+
execution_id, "_generate_execution_id", lambda: TEST_EXECUTION_ID
|
|
225
|
+
)
|
|
226
|
+
mock_g = Mock()
|
|
227
|
+
monkeypatch.setattr(execution_id.flask, "g", mock_g)
|
|
228
|
+
monkeypatch.setattr(execution_id.flask, "has_request_context", lambda: True)
|
|
229
|
+
execution_id.set_execution_context(request)(view_func)()
|
|
230
|
+
|
|
231
|
+
assert mock_g.execution_id_context.span_id == expected_span_id
|
|
232
|
+
assert mock_g.execution_id_context.execution_id == expected_execution_id
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@pytest.mark.parametrize(
|
|
236
|
+
"log_message,expected_log_json",
|
|
237
|
+
[
|
|
238
|
+
("text message", {"message": "text message"}),
|
|
239
|
+
(
|
|
240
|
+
json.dumps({"custom-field1": "value1", "custom-field2": "value2"}),
|
|
241
|
+
{"custom-field1": "value1", "custom-field2": "value2"},
|
|
242
|
+
),
|
|
243
|
+
("[]", {"message": "[]"}),
|
|
244
|
+
],
|
|
245
|
+
)
|
|
246
|
+
def test_log_handler(monkeypatch, log_message, expected_log_json, capsys):
|
|
247
|
+
log_handler = execution_id.LoggingHandlerAddExecutionId(stream=sys.stdout)
|
|
248
|
+
monkeypatch.setattr(
|
|
249
|
+
execution_id,
|
|
250
|
+
"_get_current_context",
|
|
251
|
+
lambda: execution_id.ExecutionContext(
|
|
252
|
+
span_id=TEST_SPAN_ID, execution_id=TEST_EXECUTION_ID
|
|
253
|
+
),
|
|
254
|
+
)
|
|
255
|
+
expected_log_json.update(
|
|
256
|
+
{
|
|
257
|
+
"logging.googleapis.com/labels": {
|
|
258
|
+
"execution_id": TEST_EXECUTION_ID,
|
|
259
|
+
},
|
|
260
|
+
"logging.googleapis.com/spanId": TEST_SPAN_ID,
|
|
261
|
+
}
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
log_handler.write(log_message)
|
|
265
|
+
record = capsys.readouterr()
|
|
266
|
+
assert json.loads(record.out) == expected_log_json
|
|
267
|
+
assert json.loads(record.out) == expected_log_json
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def test_log_handler_without_context_logs_unmodified(monkeypatch, capsys):
|
|
271
|
+
log_handler = execution_id.LoggingHandlerAddExecutionId(stream=sys.stdout)
|
|
272
|
+
monkeypatch.setattr(
|
|
273
|
+
execution_id,
|
|
274
|
+
"_get_current_context",
|
|
275
|
+
lambda: None,
|
|
276
|
+
)
|
|
277
|
+
expected_message = "log message\n"
|
|
278
|
+
|
|
279
|
+
log_handler.write("log message")
|
|
280
|
+
record = capsys.readouterr()
|
|
281
|
+
assert record.out == expected_message
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def test_log_handler_ignores_newlines(monkeypatch, capsys):
|
|
285
|
+
log_handler = execution_id.LoggingHandlerAddExecutionId(stream=sys.stdout)
|
|
286
|
+
monkeypatch.setattr(
|
|
287
|
+
execution_id,
|
|
288
|
+
"_get_current_context",
|
|
289
|
+
lambda: execution_id.ExecutionContext(
|
|
290
|
+
span_id=TEST_SPAN_ID, execution_id=TEST_EXECUTION_ID
|
|
291
|
+
),
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
log_handler.write("\n")
|
|
295
|
+
record = capsys.readouterr()
|
|
296
|
+
assert record.out == ""
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def test_log_handler_does_not_nest():
|
|
300
|
+
log_handler_1 = execution_id.LoggingHandlerAddExecutionId(stream=sys.stdout)
|
|
301
|
+
log_handler_2 = execution_id.LoggingHandlerAddExecutionId(log_handler_1)
|
|
302
|
+
|
|
303
|
+
assert log_handler_1 == log_handler_2
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def test_log_handler_omits_empty_execution_context(monkeypatch, capsys):
|
|
307
|
+
log_handler = execution_id.LoggingHandlerAddExecutionId(stream=sys.stdout)
|
|
308
|
+
monkeypatch.setattr(
|
|
309
|
+
execution_id,
|
|
310
|
+
"_get_current_context",
|
|
311
|
+
lambda: execution_id.ExecutionContext(span_id=None, execution_id=None),
|
|
312
|
+
)
|
|
313
|
+
expected_json = {
|
|
314
|
+
"message": "some message",
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
log_handler.write("some message")
|
|
318
|
+
record = capsys.readouterr()
|
|
319
|
+
assert json.loads(record.out) == expected_json
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@pytest.mark.asyncio
|
|
323
|
+
async def test_maintains_execution_id_for_concurrent_requests(monkeypatch, capsys):
|
|
324
|
+
monkeypatch.setenv("LOG_EXECUTION_ID", "true")
|
|
325
|
+
monkeypatch.setattr(
|
|
326
|
+
execution_id,
|
|
327
|
+
"_generate_execution_id",
|
|
328
|
+
Mock(side_effect=("test-execution-id-1", "test-execution-id-2")),
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
expected_logs = (
|
|
332
|
+
{
|
|
333
|
+
"message": "message1",
|
|
334
|
+
"logging.googleapis.com/labels": {"execution_id": "test-execution-id-1"},
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
"message": "message2",
|
|
338
|
+
"logging.googleapis.com/labels": {"execution_id": "test-execution-id-2"},
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
"message": "message1",
|
|
342
|
+
"logging.googleapis.com/labels": {"execution_id": "test-execution-id-1"},
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
"message": "message2",
|
|
346
|
+
"logging.googleapis.com/labels": {"execution_id": "test-execution-id-2"},
|
|
347
|
+
},
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
source = TEST_FUNCTIONS_DIR / "execution_id" / "main.py"
|
|
351
|
+
target = "sleep"
|
|
352
|
+
client = create_app(target, source).test_client()
|
|
353
|
+
loop = asyncio.get_event_loop()
|
|
354
|
+
response1 = loop.run_in_executor(
|
|
355
|
+
None,
|
|
356
|
+
partial(
|
|
357
|
+
client.post,
|
|
358
|
+
"/",
|
|
359
|
+
headers={
|
|
360
|
+
"Content-Type": "application/json",
|
|
361
|
+
},
|
|
362
|
+
json={"message": "message1"},
|
|
363
|
+
),
|
|
364
|
+
)
|
|
365
|
+
response2 = loop.run_in_executor(
|
|
366
|
+
None,
|
|
367
|
+
partial(
|
|
368
|
+
client.post,
|
|
369
|
+
"/",
|
|
370
|
+
headers={
|
|
371
|
+
"Content-Type": "application/json",
|
|
372
|
+
},
|
|
373
|
+
json={"message": "message2"},
|
|
374
|
+
),
|
|
375
|
+
)
|
|
376
|
+
await asyncio.wait((response1, response2))
|
|
377
|
+
record = capsys.readouterr()
|
|
378
|
+
logs = record.err.strip().split("\n")
|
|
379
|
+
logs_as_json = tuple(json.loads(log) for log in logs)
|
|
380
|
+
|
|
381
|
+
sort_key = lambda d: d["message"]
|
|
382
|
+
assert sorted(logs_as_json, key=sort_key) == sorted(expected_logs, key=sort_key)
|
|
@@ -97,17 +97,17 @@ def test_gunicorn_application(debug):
|
|
|
97
97
|
assert gunicorn_app.app == app
|
|
98
98
|
assert gunicorn_app.options == {
|
|
99
99
|
"bind": "%s:%s" % (host, port),
|
|
100
|
-
"workers":
|
|
101
|
-
"threads":
|
|
102
|
-
"timeout":
|
|
100
|
+
"workers": 1,
|
|
101
|
+
"threads": os.cpu_count() * 4,
|
|
102
|
+
"timeout": 0,
|
|
103
103
|
"loglevel": "error",
|
|
104
104
|
"limit_request_line": 0,
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
assert gunicorn_app.cfg.bind == ["1.2.3.4:1234"]
|
|
108
|
-
assert gunicorn_app.cfg.workers ==
|
|
109
|
-
assert gunicorn_app.cfg.threads ==
|
|
110
|
-
assert gunicorn_app.cfg.timeout ==
|
|
108
|
+
assert gunicorn_app.cfg.workers == 1
|
|
109
|
+
assert gunicorn_app.cfg.threads == os.cpu_count() * 4
|
|
110
|
+
assert gunicorn_app.cfg.timeout == 0
|
|
111
111
|
assert gunicorn_app.load() == app
|
|
112
112
|
|
|
113
113
|
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# Copyright 2024 Google LLC
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
import pathlib
|
|
15
|
+
import socket
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
from multiprocessing import Process
|
|
19
|
+
|
|
20
|
+
import pytest
|
|
21
|
+
import requests
|
|
22
|
+
|
|
23
|
+
ff_gunicorn = pytest.importorskip("functions_framework._http.gunicorn")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
from functions_framework import create_app
|
|
27
|
+
|
|
28
|
+
TEST_FUNCTIONS_DIR = pathlib.Path(__file__).resolve().parent / "test_functions"
|
|
29
|
+
TEST_HOST = "0.0.0.0"
|
|
30
|
+
TEST_PORT = "8080"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.fixture(autouse=True)
|
|
34
|
+
def run_around_tests():
|
|
35
|
+
# the test samples test also listens on 8080, so let's be good stewards of
|
|
36
|
+
# the port and make sure it's free
|
|
37
|
+
_wait_for_no_listen(TEST_HOST, TEST_PORT)
|
|
38
|
+
yield
|
|
39
|
+
_wait_for_no_listen(TEST_HOST, TEST_PORT)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.mark.skipif("platform.system() == 'Windows'")
|
|
43
|
+
@pytest.mark.skipif("platform.system() == 'Darwin'")
|
|
44
|
+
@pytest.mark.slow_integration_test
|
|
45
|
+
def test_no_timeout_allows_request_processing_to_finish():
|
|
46
|
+
source = TEST_FUNCTIONS_DIR / "timeout" / "main.py"
|
|
47
|
+
target = "function"
|
|
48
|
+
|
|
49
|
+
app = create_app(target, source)
|
|
50
|
+
|
|
51
|
+
options = {}
|
|
52
|
+
|
|
53
|
+
gunicorn_app = ff_gunicorn.GunicornApplication(
|
|
54
|
+
app, TEST_HOST, TEST_PORT, False, **options
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
gunicorn_p = Process(target=gunicorn_app.run)
|
|
58
|
+
gunicorn_p.start()
|
|
59
|
+
|
|
60
|
+
_wait_for_listen(TEST_HOST, TEST_PORT)
|
|
61
|
+
|
|
62
|
+
result = requests.get("http://{}:{}/".format(TEST_HOST, TEST_PORT))
|
|
63
|
+
|
|
64
|
+
gunicorn_p.terminate()
|
|
65
|
+
gunicorn_p.join()
|
|
66
|
+
|
|
67
|
+
assert result.status_code == 200
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@pytest.mark.skipif("platform.system() == 'Windows'")
|
|
71
|
+
@pytest.mark.skipif("platform.system() == 'Darwin'")
|
|
72
|
+
@pytest.mark.slow_integration_test
|
|
73
|
+
def test_timeout_but_not_threaded_timeout_enabled_does_not_kill(monkeypatch):
|
|
74
|
+
monkeypatch.setenv("CLOUD_RUN_TIMEOUT_SECONDS", "1")
|
|
75
|
+
monkeypatch.setenv("THREADED_TIMEOUT_ENABLED", "false")
|
|
76
|
+
source = TEST_FUNCTIONS_DIR / "timeout" / "main.py"
|
|
77
|
+
target = "function"
|
|
78
|
+
|
|
79
|
+
app = create_app(target, source)
|
|
80
|
+
|
|
81
|
+
options = {}
|
|
82
|
+
|
|
83
|
+
gunicorn_app = ff_gunicorn.GunicornApplication(
|
|
84
|
+
app, TEST_HOST, TEST_PORT, False, **options
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
gunicorn_p = Process(target=gunicorn_app.run)
|
|
88
|
+
gunicorn_p.start()
|
|
89
|
+
|
|
90
|
+
_wait_for_listen(TEST_HOST, TEST_PORT)
|
|
91
|
+
|
|
92
|
+
result = requests.get("http://{}:{}/".format(TEST_HOST, TEST_PORT))
|
|
93
|
+
|
|
94
|
+
gunicorn_p.terminate()
|
|
95
|
+
gunicorn_p.join()
|
|
96
|
+
|
|
97
|
+
assert result.status_code == 200
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@pytest.mark.skipif("platform.system() == 'Windows'")
|
|
101
|
+
@pytest.mark.skipif("platform.system() == 'Darwin'")
|
|
102
|
+
@pytest.mark.slow_integration_test
|
|
103
|
+
def test_timeout_and_threaded_timeout_enabled_kills(monkeypatch):
|
|
104
|
+
monkeypatch.setenv("CLOUD_RUN_TIMEOUT_SECONDS", "1")
|
|
105
|
+
monkeypatch.setenv("THREADED_TIMEOUT_ENABLED", "true")
|
|
106
|
+
source = TEST_FUNCTIONS_DIR / "timeout" / "main.py"
|
|
107
|
+
target = "function"
|
|
108
|
+
|
|
109
|
+
app = create_app(target, source)
|
|
110
|
+
|
|
111
|
+
options = {}
|
|
112
|
+
|
|
113
|
+
gunicorn_app = ff_gunicorn.GunicornApplication(
|
|
114
|
+
app, TEST_HOST, TEST_PORT, False, **options
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
gunicorn_p = Process(target=gunicorn_app.run)
|
|
118
|
+
gunicorn_p.start()
|
|
119
|
+
|
|
120
|
+
_wait_for_listen(TEST_HOST, TEST_PORT)
|
|
121
|
+
|
|
122
|
+
result = requests.get("http://{}:{}/".format(TEST_HOST, TEST_PORT))
|
|
123
|
+
|
|
124
|
+
gunicorn_p.terminate()
|
|
125
|
+
gunicorn_p.join()
|
|
126
|
+
|
|
127
|
+
# Any exception raised in execution is a 500 error. Cloud Functions 1st gen and
|
|
128
|
+
# 2nd gen/Cloud Run infrastructure doing the timeout will return a 408 (gen 1)
|
|
129
|
+
# or 504 (gen 2/CR) at the infrastructure layer when request timeouts happen,
|
|
130
|
+
# and this code will only be available to the user in logs.
|
|
131
|
+
assert result.status_code == 500
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@pytest.mark.skipif("platform.system() == 'Windows'")
|
|
135
|
+
@pytest.mark.skipif("platform.system() == 'Darwin'")
|
|
136
|
+
@pytest.mark.slow_integration_test
|
|
137
|
+
def test_timeout_and_threaded_timeout_enabled_but_timeout_not_exceeded_doesnt_kill(
|
|
138
|
+
monkeypatch,
|
|
139
|
+
):
|
|
140
|
+
monkeypatch.setenv("CLOUD_RUN_TIMEOUT_SECONDS", "2")
|
|
141
|
+
monkeypatch.setenv("THREADED_TIMEOUT_ENABLED", "true")
|
|
142
|
+
source = TEST_FUNCTIONS_DIR / "timeout" / "main.py"
|
|
143
|
+
target = "function"
|
|
144
|
+
|
|
145
|
+
app = create_app(target, source)
|
|
146
|
+
|
|
147
|
+
options = {}
|
|
148
|
+
|
|
149
|
+
gunicorn_app = ff_gunicorn.GunicornApplication(
|
|
150
|
+
app, TEST_HOST, TEST_PORT, False, **options
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
gunicorn_p = Process(target=gunicorn_app.run)
|
|
154
|
+
gunicorn_p.start()
|
|
155
|
+
|
|
156
|
+
_wait_for_listen(TEST_HOST, TEST_PORT)
|
|
157
|
+
|
|
158
|
+
result = requests.get("http://{}:{}/".format(TEST_HOST, TEST_PORT))
|
|
159
|
+
|
|
160
|
+
gunicorn_p.terminate()
|
|
161
|
+
gunicorn_p.join()
|
|
162
|
+
|
|
163
|
+
assert result.status_code == 200
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@pytest.mark.skipif("platform.system() == 'Windows'")
|
|
167
|
+
@pytest.mark.skipif("platform.system() == 'Darwin'")
|
|
168
|
+
@pytest.mark.slow_integration_test
|
|
169
|
+
def test_timeout_sync_worker_kills_on_timeout(
|
|
170
|
+
monkeypatch,
|
|
171
|
+
):
|
|
172
|
+
monkeypatch.setenv("CLOUD_RUN_TIMEOUT_SECONDS", "1")
|
|
173
|
+
monkeypatch.setenv("WORKERS", 2)
|
|
174
|
+
monkeypatch.setenv("THREADS", 1)
|
|
175
|
+
source = TEST_FUNCTIONS_DIR / "timeout" / "main.py"
|
|
176
|
+
target = "function"
|
|
177
|
+
|
|
178
|
+
app = create_app(target, source)
|
|
179
|
+
|
|
180
|
+
options = {}
|
|
181
|
+
|
|
182
|
+
gunicorn_app = ff_gunicorn.GunicornApplication(
|
|
183
|
+
app, TEST_HOST, TEST_PORT, False, **options
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
gunicorn_p = Process(target=gunicorn_app.run)
|
|
187
|
+
gunicorn_p.start()
|
|
188
|
+
|
|
189
|
+
_wait_for_listen(TEST_HOST, TEST_PORT)
|
|
190
|
+
|
|
191
|
+
result = requests.get("http://{}:{}/".format(TEST_HOST, TEST_PORT))
|
|
192
|
+
|
|
193
|
+
gunicorn_p.terminate()
|
|
194
|
+
gunicorn_p.join()
|
|
195
|
+
|
|
196
|
+
assert result.status_code == 500
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@pytest.mark.skipif("platform.system() == 'Windows'")
|
|
200
|
+
@pytest.mark.skipif("platform.system() == 'Darwin'")
|
|
201
|
+
@pytest.mark.slow_integration_test
|
|
202
|
+
def test_timeout_sync_worker_does_not_kill_if_less_than_timeout(
|
|
203
|
+
monkeypatch,
|
|
204
|
+
):
|
|
205
|
+
monkeypatch.setenv("CLOUD_RUN_TIMEOUT_SECONDS", "2")
|
|
206
|
+
monkeypatch.setenv("WORKERS", 2)
|
|
207
|
+
monkeypatch.setenv("THREADS", 1)
|
|
208
|
+
source = TEST_FUNCTIONS_DIR / "timeout" / "main.py"
|
|
209
|
+
target = "function"
|
|
210
|
+
|
|
211
|
+
app = create_app(target, source)
|
|
212
|
+
|
|
213
|
+
options = {}
|
|
214
|
+
|
|
215
|
+
gunicorn_app = ff_gunicorn.GunicornApplication(
|
|
216
|
+
app, TEST_HOST, TEST_PORT, False, **options
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
gunicorn_p = Process(target=gunicorn_app.run)
|
|
220
|
+
gunicorn_p.start()
|
|
221
|
+
|
|
222
|
+
_wait_for_listen(TEST_HOST, TEST_PORT)
|
|
223
|
+
|
|
224
|
+
result = requests.get("http://{}:{}/".format(TEST_HOST, TEST_PORT))
|
|
225
|
+
|
|
226
|
+
gunicorn_p.terminate()
|
|
227
|
+
gunicorn_p.join()
|
|
228
|
+
|
|
229
|
+
assert result.status_code == 200
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@pytest.mark.skip
|
|
233
|
+
def _wait_for_listen(host, port, timeout=10):
|
|
234
|
+
# Used in tests to make sure that the gunicorn app has booted and is
|
|
235
|
+
# listening before sending a test request
|
|
236
|
+
start_time = time.perf_counter()
|
|
237
|
+
while True:
|
|
238
|
+
try:
|
|
239
|
+
with socket.create_connection((host, port), timeout=timeout):
|
|
240
|
+
break
|
|
241
|
+
except OSError as ex:
|
|
242
|
+
time.sleep(0.01)
|
|
243
|
+
if time.perf_counter() - start_time >= timeout:
|
|
244
|
+
raise TimeoutError(
|
|
245
|
+
"Waited too long for port {} on host {} to start accepting "
|
|
246
|
+
"connections.".format(port, host)
|
|
247
|
+
) from ex
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@pytest.mark.skip
|
|
251
|
+
def _wait_for_no_listen(host, port, timeout=10):
|
|
252
|
+
# Used in tests to make sure that the port is actually free after
|
|
253
|
+
# the process binding to it should have been killed
|
|
254
|
+
start_time = time.perf_counter()
|
|
255
|
+
while True:
|
|
256
|
+
try:
|
|
257
|
+
with socket.create_connection((host, port), timeout=timeout):
|
|
258
|
+
time.sleep(0.01)
|
|
259
|
+
if time.perf_counter() - start_time >= timeout:
|
|
260
|
+
raise TimeoutError(
|
|
261
|
+
"Waited too long for port {} on host {} to stop accepting "
|
|
262
|
+
"connections.".format(port, host)
|
|
263
|
+
)
|
|
264
|
+
except OSError as ex:
|
|
265
|
+
break
|
|
@@ -25,7 +25,7 @@ import functions_framework
|
|
|
25
25
|
def test_http_view_func_wrapper():
|
|
26
26
|
function = pretend.call_recorder(lambda request: "Hello")
|
|
27
27
|
request_object = pretend.stub()
|
|
28
|
-
local_proxy = pretend.stub(_get_current_object=lambda: request_object)
|
|
28
|
+
local_proxy = pretend.stub(_get_current_object=lambda: request_object, headers={})
|
|
29
29
|
|
|
30
30
|
view_func = functions_framework._http_view_func_wrapper(function, local_proxy)
|
|
31
31
|
view_func("/some/path")
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
# Copyright 2020 Google LLC
|
|
2
|
-
#
|
|
3
|
-
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
-
# you may not use this file except in compliance with the License.
|
|
5
|
-
# You may obtain a copy of the License at
|
|
6
|
-
#
|
|
7
|
-
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
-
#
|
|
9
|
-
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
-
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
-
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
-
# See the License for the specific language governing permissions and
|
|
13
|
-
# limitations under the License.
|
|
14
|
-
|
|
15
|
-
import os
|
|
16
|
-
|
|
17
|
-
import gunicorn.app.base
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
class GunicornApplication(gunicorn.app.base.BaseApplication):
|
|
21
|
-
def __init__(self, app, host, port, debug, **options):
|
|
22
|
-
self.options = {
|
|
23
|
-
"bind": "%s:%s" % (host, port),
|
|
24
|
-
"workers": os.environ.get("WORKERS", (os.cpu_count() or 1) * 4),
|
|
25
|
-
"threads": os.environ.get("THREADS", 1),
|
|
26
|
-
"timeout": os.environ.get("CLOUD_RUN_TIMEOUT_SECONDS", 300),
|
|
27
|
-
"loglevel": "error",
|
|
28
|
-
"limit_request_line": 0,
|
|
29
|
-
}
|
|
30
|
-
self.options.update(options)
|
|
31
|
-
self.app = app
|
|
32
|
-
|
|
33
|
-
super().__init__()
|
|
34
|
-
|
|
35
|
-
def load_config(self):
|
|
36
|
-
for key, value in self.options.items():
|
|
37
|
-
self.cfg.set(key, value)
|
|
38
|
-
|
|
39
|
-
def load(self):
|
|
40
|
-
return self.app
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework/_http/__init__.py
RENAMED
|
File without changes
|
{functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework/_http/flask.py
RENAMED
|
File without changes
|
{functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework/_typed_event.py
RENAMED
|
File without changes
|
{functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework/background_event.py
RENAMED
|
File without changes
|
{functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework/event_conversion.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{functions_framework-3.6.0 → functions_framework-3.8.0}/src/google/cloud/functions/__init__.py
RENAMED
|
File without changes
|
{functions_framework-3.6.0 → functions_framework-3.8.0}/src/google/cloud/functions/context.py
RENAMED
|
File without changes
|
{functions_framework-3.6.0 → functions_framework-3.8.0}/src/google/cloud/functions_v1/__init__.py
RENAMED
|
File without changes
|
{functions_framework-3.6.0 → functions_framework-3.8.0}/src/google/cloud/functions_v1/context.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|