From ad3406dde978312cecc085acb717e722f9251339 Mon Sep 17 00:00:00 2001 From: Arjun Srinivasan <69502+asriniva@users.noreply.github.com> Date: Wed, 17 Feb 2021 12:56:16 -0800 Subject: [PATCH] Add backwards-compatible logging for GCF Python 3.7 (#107) * Add backwards-compatible logging for GCF Python 3.7 * Reformatted * Update src/functions_framework/__init__.py Co-authored-by: Dustin Ingram * Restructure logging to better fit legacy behavior * Modify write behavior to account for newlines * Update LogHandler to use io.TextIOWrapper * Simplify write method * Update src/functions_framework/__init__.py Co-authored-by: Dustin Ingram Co-authored-by: Dustin Ingram --- conftest.py | 16 ++++++ src/functions_framework/__init__.py | 25 ++++++++++ tests/test_functions.py | 25 ++++++++++ .../http_check_severity/main.py | 49 +++++++++++++++++++ 4 files changed, 115 insertions(+) create mode 100644 tests/test_functions/http_check_severity/main.py diff --git a/conftest.py b/conftest.py index b8d44d43..21572fda 100644 --- a/conftest.py +++ b/conftest.py @@ -12,7 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. +import logging import os +import sys + +from importlib import reload import pytest @@ -26,3 +30,15 @@ def isolate_environment(): finally: os.environ.clear() os.environ.update(_environ) + + +@pytest.fixture(scope="function", autouse=True) +def isolate_logging(): + "Ensure any changes to logging are isolated to individual tests" "" + try: + yield + finally: + sys.stdout = sys.__stdout__ + sys.stderr = sys.__stderr__ + logging.shutdown() + reload(logging) diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index 356f7a03..1e87fdb4 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -14,6 +14,7 @@ import functools import importlib.util +import io import json import os.path import pathlib @@ -65,6 +66,19 @@ def __init__( self.data = data +class _LoggingHandler(io.TextIOWrapper): + """Logging replacement for stdout and stderr in GCF Python 3.7.""" + + def __init__(self, level, stderr=sys.stderr): + io.TextIOWrapper.__init__(self, io.StringIO(), encoding=stderr.encoding) + self.level = level + self.stderr = stderr + + def write(self, out): + payload = dict(severity=self.level, message=out.rstrip("\n")) + return self.stderr.write(json.dumps(payload) + "\n") + + def _http_view_func_wrapper(function, request): def view_func(path): return function(request._get_current_object()) @@ -221,6 +235,17 @@ def handle_none(rv): app.make_response = handle_none + # Handle log severity backwards compatibility + import logging # isort:skip + + logging.info = _LoggingHandler("INFO", sys.stderr).write + logging.warn = _LoggingHandler("ERROR", sys.stderr).write + logging.warning = _LoggingHandler("ERROR", sys.stderr).write + logging.error = _LoggingHandler("ERROR", sys.stderr).write + logging.critical = _LoggingHandler("ERROR", sys.stderr).write + sys.stdout = _LoggingHandler("INFO", sys.stderr) + sys.stderr = _LoggingHandler("ERROR", sys.stderr) + # Extract the target function from the source file if not hasattr(source_module, target): raise MissingTargetException( diff --git a/tests/test_functions.py b/tests/test_functions.py index 2f0ca05b..5f746931 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -16,6 +16,7 @@ import os import pathlib import re +import sys import time import pretend @@ -495,6 +496,30 @@ def test_legacy_function_check_env(monkeypatch): assert resp.data.decode("utf-8") == target +@pytest.mark.parametrize( + "mode, expected", + [ + ("loginfo", '"severity": "INFO"'), + ("logwarn", '"severity": "ERROR"'), + ("logerr", '"severity": "ERROR"'), + ("logcrit", '"severity": "ERROR"'), + ("stdout", '"severity": "INFO"'), + ("stderr", '"severity": "ERROR"'), + ], +) +def test_legacy_function_log_severity(monkeypatch, capfd, mode, expected): + source = TEST_FUNCTIONS_DIR / "http_check_severity" / "main.py" + target = "function" + + monkeypatch.setenv("ENTRY_POINT", target) + + client = create_app(target, source).test_client() + resp = client.post("/", json={"mode": mode}) + captured = capfd.readouterr().err + assert resp.status_code == 200 + assert expected in captured + + def test_legacy_function_returns_none(monkeypatch): source = TEST_FUNCTIONS_DIR / "returns_none" / "main.py" target = "function" diff --git a/tests/test_functions/http_check_severity/main.py b/tests/test_functions/http_check_severity/main.py new file mode 100644 index 00000000..be586d8d --- /dev/null +++ b/tests/test_functions/http_check_severity/main.py @@ -0,0 +1,49 @@ +# Copyright 2020 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Function used in Worker tests of legacy GCF Python 3.7 logging.""" +import logging +import os +import sys + +X_GOOGLE_FUNCTION_NAME = "gcf-function" +X_GOOGLE_ENTRY_POINT = "function" +HOME = "/tmp" + + +def function(request): + """Test function which logs to the appropriate output. + + Args: + request: The HTTP request which triggered this function. Must contain name + of the requested output in the 'mode' field in JSON document + in request body. + + Returns: + Value of the mode. + """ + name = request.get_json().get("mode") + if name == "stdout": + print("log") + elif name == "stderr": + print("log", file=sys.stderr) + elif name == "loginfo": + logging.info("log") + elif name == "logwarn": + logging.warning("log") + elif name == "logerr": + logging.error("log") + elif name == "logcrit": + logging.critical("log") + return name