From a0aca38cddc3e5d857d31fd07350a5e632f4eef2 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Tue, 18 Jul 2023 14:20:24 +0100 Subject: [PATCH] feat: initial run at logger module --- src/firebase_functions/logger.py | 137 +++++++++++++++++++++++++++++++ tests/test_logger.py | 100 ++++++++++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 src/firebase_functions/logger.py create mode 100644 tests/test_logger.py diff --git a/src/firebase_functions/logger.py b/src/firebase_functions/logger.py new file mode 100644 index 0000000..3b36886 --- /dev/null +++ b/src/firebase_functions/logger.py @@ -0,0 +1,137 @@ +import enum as _enum +import json as _json +import typing as _typing +import typing_extensions as _typing_extensions +import logging as _logging +import sys as _sys + +log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" +stream_handler = _logging.StreamHandler(_sys.stdout) + +_logging.basicConfig(format=log_format, level=_logging.DEBUG, handlers=[stream_handler]) + + +logger = _logging.getLogger() +logger.setLevel(_logging.DEBUG) + + +class LogSeverity(str, _enum.Enum): + """ + `LogSeverity` indicates the detailed severity of the log entry. See [LogSeverity](https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#logseverity). + """ + DEBUG = "DEBUG" + INFO = "INFO" + NOTICE = "NOTICE" + WARNING = "WARNING" + ERROR = "ERROR" + CRITICAL = "CRITICAL" + ALERT = "ALERT" + EMERGENCY = "EMERGENCY" + + +class LogEntry(_typing.TypedDict): + """ + `LogEntry` represents a log entry. + See [LogEntry](https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry). + """ + severity: _typing_extensions.Required[LogSeverity] + message: _typing_extensions.NotRequired[str] + +# class LogEntry(_google_cloud_logging.LogEntry): +# # is this the correct way to reexport/extend the class? +# def __init__(self, **kwargs): +# super().__init__(self,**kwargs) +# pass + +# a function that removes circular references and replaces them with [Circular] +def _replace_circular(obj, seen=None): + if seen is None: + seen = set() + + if (id(obj) in seen): + return "[CIRCULAR]" + if not isinstance(obj, (str, int, float, bool, type(None))): + seen.add(id(obj)) + + if isinstance(obj, dict): + return { + key: _replace_circular(value, seen) for key, value in obj.items() + } + elif isinstance(obj, list): + return [_replace_circular(value, seen) for i, value in enumerate(obj)] + elif isinstance(obj, tuple): + return tuple(_replace_circular(value, seen) for i, value in enumerate(obj)) + else: + return obj + + +def _entry_from_args(severity: LogSeverity, **kwargs) -> LogEntry: + """ + Creates a `LogEntry` from the given arguments. + """ + + message: str = " ".join( + # do we need to replace_circular here? + [value if isinstance(value,str) else _json.dumps(_replace_circular(value)) for value in kwargs.values() ] + ) + + return { + "severity": severity, + "message": message + } + +def write(entry: LogEntry) -> None: + """ + Writes a `LogEntry` to `stdout`/`stderr` (depending on severity). + """ + severity = entry['severity'] + + out = _json.dumps(_replace_circular(entry)) + + match severity: + case LogSeverity.DEBUG: + logger.debug(out) + case LogSeverity.INFO: + logger.info(out) + case LogSeverity.NOTICE: + logger.info(out) + case LogSeverity.WARNING: + logger.warning(out) + case LogSeverity.ERROR: + logger.error(out) + case LogSeverity.CRITICAL: + logger.critical(out) + case LogSeverity.ALERT: + logger.critical(out) + case LogSeverity.EMERGENCY: + _logging.critical(out) + +def debug(**kwargs) -> None: + """ + Logs a debug message. + """ + write(_entry_from_args(LogSeverity.DEBUG, **kwargs)) + +def log(**kwargs) -> None: + """ + Logs a log message. + """ + write(_entry_from_args(LogSeverity.NOTICE, **kwargs)) + +def info(**kwargs) -> None: + """ + Logs an info message. + """ + write(_entry_from_args(LogSeverity.INFO, **kwargs)) + +def warn(**kwargs) -> None: + """ + Logs a warning message. + """ + write(_entry_from_args(LogSeverity.WARNING, **kwargs)) + +def error(**kwargs) -> None: + """ + Logs an error message. + """ + write(_entry_from_args(LogSeverity.ERROR, **kwargs)) \ No newline at end of file diff --git a/tests/test_logger.py b/tests/test_logger.py new file mode 100644 index 0000000..d6ee36f --- /dev/null +++ b/tests/test_logger.py @@ -0,0 +1,100 @@ +# Copyright 2022 Google Inc. +# +# 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. +"""Logger unit tests.""" + +from firebase_functions.logger import _replace_circular,_entry_from_args,LogSeverity,write,LogEntry +from unittest import TestCase +from unittest.mock import patch +from io import StringIO + +class TestReplaceCircular(TestCase): + """ + Tests for path utilities. + """ + def test_basic_recursed_dict(self): + + a = { + "foo": "bar", + } + a["bax"] = a + + self.assertEqual(_replace_circular(a), { "foo": "bar", "bax": "[CIRCULAR]" }) + + def test_complex_recursed_dict(self): + + a = { + "foo": "bar", + } + a["bax"] = a + + b = { + "foo": "bar", + } + b["bax"] = a + + self.assertEqual(_replace_circular(b), { "foo": "bar", "bax": { "foo": "bar", "bax": "[CIRCULAR]"} }) + + def test_basic_recursed_list(self): + + a = [ + "foo", + ] + a.append(a) + + self.assertEqual(_replace_circular(a), [ "foo", "[CIRCULAR]" ]) + + def test_tuple_containing_circular(self): + + b = { + "foo": "bar", + } + b["bax"] = b + + a = ( + b, + ) + + self.assertEqual(_replace_circular(a), ({'foo': 'bar', 'bax': '[CIRCULAR]'},)) + + def test_immutables(self): + self.assertEqual(_replace_circular("foo"), "foo") + self.assertEqual(_replace_circular(1), 1) + self.assertEqual(_replace_circular(1.0), 1.0) + self.assertEqual(_replace_circular(True), True) + self.assertEqual(_replace_circular(None), None) + self.assertEqual(_replace_circular((1,)), (1,)) + +class TestEntryFromArgs(TestCase): + """ + Tests for entry_from_args. + """ + def test_basic_debug(self): + # should this be coming through with the severity enum or as a literal string? + self.assertEqual(_entry_from_args(LogSeverity.DEBUG,message="123"), { "message": "123", "severity": LogSeverity.DEBUG }) + + def test_basic_with_kwargs(self): + entry = _entry_from_args(LogSeverity.DEBUG,message="123",foo="bar") + self.assertEqual(entry, { "message": "123 bar", "severity": LogSeverity.DEBUG }) + + +class TestWrite(TestCase): + """ + Tests for write. + """ + @patch('sys.stdout', new_callable=StringIO) + def test(self,caplog): + entry = _entry_from_args(LogSeverity.EMERGENCY,message="123",foo="bar") + + write(entry) + self.assertEqual(mock_stdout.getvalue(),'hello\n') \ No newline at end of file