Skip to content

Commit 90211ab

Browse files
exaby73taeold
andauthored
feat: implement logger (#121)
Co-authored-by: Daniel Lee <[email protected]>
1 parent 1727f50 commit 90211ab

File tree

4 files changed

+214
-1
lines changed

4 files changed

+214
-1
lines changed

src/firebase_functions/logger.py

+130
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
"""
2+
Logger module for Firebase Functions.
3+
"""
4+
5+
import enum as _enum
6+
import json as _json
7+
import sys as _sys
8+
import typing as _typing
9+
import typing_extensions as _typing_extensions
10+
11+
12+
class LogSeverity(str, _enum.Enum):
13+
"""
14+
`LogSeverity` indicates the detailed severity of the log entry. See
15+
[LogSeverity](https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#logseverity).
16+
"""
17+
18+
DEBUG = "DEBUG"
19+
INFO = "INFO"
20+
NOTICE = "NOTICE"
21+
WARNING = "WARNING"
22+
ERROR = "ERROR"
23+
CRITICAL = "CRITICAL"
24+
ALERT = "ALERT"
25+
EMERGENCY = "EMERGENCY"
26+
27+
28+
class LogEntry(_typing.TypedDict):
29+
"""
30+
`LogEntry` represents a log entry.
31+
See [LogEntry](https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry).
32+
"""
33+
34+
severity: _typing_extensions.Required[LogSeverity]
35+
message: _typing_extensions.NotRequired[str]
36+
37+
38+
def _entry_from_args(severity: LogSeverity, *args, **kwargs) -> LogEntry:
39+
"""
40+
Creates a `LogEntry` from the given arguments.
41+
"""
42+
43+
message: str = " ".join([
44+
value
45+
if isinstance(value, str) else _json.dumps(_remove_circular(value))
46+
for value in args
47+
])
48+
49+
other: _typing.Dict[str, _typing.Any] = {
50+
key: value if isinstance(value, str) else _remove_circular(value)
51+
for key, value in kwargs.items()
52+
}
53+
54+
entry: _typing.Dict[str, _typing.Any] = {"severity": severity, **other}
55+
if message:
56+
entry["message"] = message
57+
58+
return _typing.cast(LogEntry, entry)
59+
60+
61+
def _remove_circular(obj: _typing.Any,
62+
refs: _typing.Set[_typing.Any] | None = None):
63+
"""
64+
Removes circular references from the given object and replaces them with "[CIRCULAR]".
65+
"""
66+
67+
if refs is None:
68+
refs = set()
69+
70+
if id(obj) in refs:
71+
return "[CIRCULAR]"
72+
73+
if not isinstance(obj, (str, int, float, bool, type(None))):
74+
refs.add(id(obj))
75+
76+
if isinstance(obj, dict):
77+
return {key: _remove_circular(value, refs) for key, value in obj.items()}
78+
elif isinstance(obj, list):
79+
return [_remove_circular(value, refs) for _, value in enumerate(obj)]
80+
elif isinstance(obj, tuple):
81+
return tuple(
82+
_remove_circular(value, refs) for _, value in enumerate(obj))
83+
else:
84+
return obj
85+
86+
87+
def _get_write_file(severity: LogSeverity) -> _typing.TextIO:
88+
if severity == LogSeverity.ERROR:
89+
return _sys.stderr
90+
return _sys.stdout
91+
92+
93+
def write(entry: LogEntry) -> None:
94+
write_file = _get_write_file(entry["severity"])
95+
print(_json.dumps(_remove_circular(entry)), file=write_file)
96+
97+
98+
def debug(*args, **kwargs) -> None:
99+
"""
100+
Logs a debug message.
101+
"""
102+
write(_entry_from_args(LogSeverity.DEBUG, *args, **kwargs))
103+
104+
105+
def log(*args, **kwargs) -> None:
106+
"""
107+
Logs a log message.
108+
"""
109+
write(_entry_from_args(LogSeverity.NOTICE, *args, **kwargs))
110+
111+
112+
def info(*args, **kwargs) -> None:
113+
"""
114+
Logs an info message.
115+
"""
116+
write(_entry_from_args(LogSeverity.INFO, *args, **kwargs))
117+
118+
119+
def warn(*args, **kwargs) -> None:
120+
"""
121+
Logs a warning message.
122+
"""
123+
write(_entry_from_args(LogSeverity.WARNING, *args, **kwargs))
124+
125+
126+
def error(*args, **kwargs) -> None:
127+
"""
128+
Logs an error message.
129+
"""
130+
write(_entry_from_args(LogSeverity.ERROR, *args, **kwargs))

src/firebase_functions/private/_identity_fn.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ def _auth_user_record_from_token_data(token_data: dict[str, _typing.Any]):
114114
return AuthUserRecord(
115115
uid=token_data["uid"],
116116
email=token_data.get("email"),
117-
email_verified=token_data.get("email_verified"),
117+
email_verified=bool(token_data.get("email_verified")),
118118
display_name=token_data.get("display_name"),
119119
photo_url=token_data.get("photo_url"),
120120
phone_number=token_data.get("phone_number"),

src/firebase_functions/private/util.py

+2
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,8 @@ def timestamp_conversion(time: str) -> _dt.datetime:
387387
elif precision_timestamp == PrecisionTimestamp.SECONDS:
388388
return second_timestamp_conversion(time)
389389

390+
raise ValueError("Invalid timestamp")
391+
390392

391393
def microsecond_timestamp_conversion(time: str) -> _dt.datetime:
392394
"""Converts a microsecond timestamp and returns a datetime object of the current time in UTC"""

tests/test_logger.py

+81
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""
2+
Logger module tests.
3+
"""
4+
5+
import pytest
6+
import json
7+
from firebase_functions import logger
8+
9+
10+
class TestLogger:
11+
"""
12+
Tests for the logger module.
13+
"""
14+
15+
def test_format_should_be_valid_json(self,
16+
capsys: pytest.CaptureFixture[str]):
17+
logger.log(foo="bar")
18+
raw_log_output = capsys.readouterr().out
19+
try:
20+
json.loads(raw_log_output)
21+
except json.JSONDecodeError:
22+
pytest.fail("Log output was not valid JSON.")
23+
24+
def test_log_should_have_severity(self, capsys: pytest.CaptureFixture[str]):
25+
logger.log(foo="bar")
26+
raw_log_output = capsys.readouterr().out
27+
log_output = json.loads(raw_log_output)
28+
assert "severity" in log_output
29+
30+
def test_severity_should_be_debug(self, capsys: pytest.CaptureFixture[str]):
31+
logger.debug(foo="bar")
32+
raw_log_output = capsys.readouterr().out
33+
log_output = json.loads(raw_log_output)
34+
assert log_output["severity"] == "DEBUG"
35+
36+
def test_severity_should_be_notice(self,
37+
capsys: pytest.CaptureFixture[str]):
38+
logger.log(foo="bar")
39+
raw_log_output = capsys.readouterr().out
40+
log_output = json.loads(raw_log_output)
41+
assert log_output["severity"] == "NOTICE"
42+
43+
def test_severity_should_be_info(self, capsys: pytest.CaptureFixture[str]):
44+
logger.info(foo="bar")
45+
raw_log_output = capsys.readouterr().out
46+
log_output = json.loads(raw_log_output)
47+
assert log_output["severity"] == "INFO"
48+
49+
def test_severity_should_be_warning(self,
50+
capsys: pytest.CaptureFixture[str]):
51+
logger.warn(foo="bar")
52+
raw_log_output = capsys.readouterr().out
53+
log_output = json.loads(raw_log_output)
54+
assert log_output["severity"] == "WARNING"
55+
56+
def test_severity_should_be_error(self, capsys: pytest.CaptureFixture[str]):
57+
logger.error(foo="bar")
58+
raw_log_output = capsys.readouterr().err
59+
log_output = json.loads(raw_log_output)
60+
assert log_output["severity"] == "ERROR"
61+
62+
def test_log_should_have_message(self, capsys: pytest.CaptureFixture[str]):
63+
logger.log("bar")
64+
raw_log_output = capsys.readouterr().out
65+
log_output = json.loads(raw_log_output)
66+
assert "message" in log_output
67+
68+
def test_log_should_have_other_keys(self,
69+
capsys: pytest.CaptureFixture[str]):
70+
logger.log(foo="bar")
71+
raw_log_output = capsys.readouterr().out
72+
log_output = json.loads(raw_log_output)
73+
assert "foo" in log_output
74+
75+
def test_message_should_be_space_separated(
76+
self, capsys: pytest.CaptureFixture[str]):
77+
logger.log("bar", "qux")
78+
expected_message = "bar qux"
79+
raw_log_output = capsys.readouterr().out
80+
log_output = json.loads(raw_log_output)
81+
assert log_output["message"] == expected_message

0 commit comments

Comments
 (0)