apppy-logger 0.15.1__py3-none-any.whl
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.
- apppy/logger/__init__.py +72 -0
- apppy/logger/filter.py +19 -0
- apppy/logger/format.py +62 -0
- apppy/logger/format_unit_test.py +127 -0
- apppy/logger/parser.py +54 -0
- apppy/logger/storage.py +65 -0
- apppy_logger-0.15.1.dist-info/METADATA +10 -0
- apppy_logger-0.15.1.dist-info/RECORD +9 -0
- apppy_logger-0.15.1.dist-info/WHEEL +4 -0
apppy/logger/__init__.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from apppy.logger.filter import SuppressApiLoggingFilter
|
|
5
|
+
from apppy.logger.format import ExtraLoggingFormatter, NewLineTerminator
|
|
6
|
+
from apppy.logger.parser import LogRecordParser
|
|
7
|
+
from apppy.logger.storage import LoggingStorage
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def bootstrap_global_logging(level, stdout=False):
|
|
11
|
+
"""
|
|
12
|
+
Utility to apply custom logging configuration
|
|
13
|
+
to application loggers. It should be called very
|
|
14
|
+
early in the application boot process.
|
|
15
|
+
"""
|
|
16
|
+
if isinstance(level, str):
|
|
17
|
+
level = logging._nameToLevel[level.upper()]
|
|
18
|
+
|
|
19
|
+
logging.root.setLevel(level)
|
|
20
|
+
|
|
21
|
+
if stdout:
|
|
22
|
+
# Configure the logging system to use stdout.
|
|
23
|
+
# This should be used for frameworks with minimal logging configuration (e.g. AWS Lambda)
|
|
24
|
+
# For mature application frameworks (e.g. uvicorn), do not use the stdout flag.
|
|
25
|
+
logging.basicConfig(level=level, handlers=[logging.StreamHandler(sys.stdout)], force=True)
|
|
26
|
+
|
|
27
|
+
logging.getLogger("httpcore").setLevel(logging.INFO)
|
|
28
|
+
logging.getLogger("httpcore.connection").setLevel(logging.INFO)
|
|
29
|
+
logging.getLogger("httpcore.http11").setLevel(logging.INFO)
|
|
30
|
+
|
|
31
|
+
# Setup global singletons
|
|
32
|
+
LogRecordParser.set_global()
|
|
33
|
+
LoggingStorage.set_global()
|
|
34
|
+
# Apply formatters to all loggers so that their
|
|
35
|
+
# output is uniform
|
|
36
|
+
ExtraLoggingFormatter.apply(logging.root)
|
|
37
|
+
ExtraLoggingFormatter.apply_all()
|
|
38
|
+
# Setup the filter to add Logging Storage state
|
|
39
|
+
# to each log record
|
|
40
|
+
LoggingStorage.apply_all()
|
|
41
|
+
|
|
42
|
+
if stdout:
|
|
43
|
+
# If we configured stdout (see comment above) then
|
|
44
|
+
# ensure logs go on separate lines
|
|
45
|
+
NewLineTerminator.apply_all()
|
|
46
|
+
|
|
47
|
+
# A /health endpoint is typically called a lot in deployed environments
|
|
48
|
+
# as part of load balancer checks. This bloats the application logs
|
|
49
|
+
# so this filter allows health checks to be skipped in logs.
|
|
50
|
+
SuppressApiLoggingFilter.apply(logging.getLogger("uvicorn.access"), "/health")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class WithLogger:
|
|
54
|
+
"""
|
|
55
|
+
Class decorator that injects a _logger object using a very standard
|
|
56
|
+
naming convention. The convention is based on the class name so that
|
|
57
|
+
log lines can be easily associated with a given class. For example:
|
|
58
|
+
|
|
59
|
+
class MyService(WithLogger):
|
|
60
|
+
...
|
|
61
|
+
def do_something(self):
|
|
62
|
+
# This logger will be named with the module and class names
|
|
63
|
+
# e.g. 'module.path.to.MyService'
|
|
64
|
+
self._logger.info("I am doing something")
|
|
65
|
+
...
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
_logger: logging.Logger
|
|
69
|
+
|
|
70
|
+
def __init_subclass__(cls, **kwargs):
|
|
71
|
+
super().__init_subclass__(**kwargs)
|
|
72
|
+
cls._logger = logging.getLogger(f"{cls.__module__}.{cls.__qualname__}")
|
apppy/logger/filter.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SuppressApiLoggingFilter(logging.Filter):
|
|
5
|
+
"""
|
|
6
|
+
Suppress HTTP logs to an API endpoint via its path.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
def __init__(self, path: str):
|
|
10
|
+
super().__init__(f"SuppressApiLoggingFilter_{path}")
|
|
11
|
+
self._path = path
|
|
12
|
+
|
|
13
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
14
|
+
return record.getMessage().find(self._path) == -1
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def apply(cls, logger: logging.Logger, path: str):
|
|
18
|
+
filter = cls(path)
|
|
19
|
+
logger.addFilter(filter)
|
apppy/logger/format.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from apppy.logger.parser import LogRecordParser
|
|
4
|
+
|
|
5
|
+
fmt_default = "%(asctime)s | %(levelname)s | %(name)s | %(message)s"
|
|
6
|
+
datefmt_default = "%Y-%m-%dT%H:%M:%SZ%z"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ExtraLoggingFormatter(logging.Formatter):
|
|
10
|
+
def __init__(self, fmt=None, datefmt=None, style="%"):
|
|
11
|
+
super().__init__(fmt, datefmt, style)
|
|
12
|
+
|
|
13
|
+
def format(self, record):
|
|
14
|
+
message = super().format(record)
|
|
15
|
+
|
|
16
|
+
state_info = LogRecordParser.get_global().parse_state_info(record)
|
|
17
|
+
if state_info:
|
|
18
|
+
state_str = " | ".join(f"{key}={value}" for key, value in state_info.items())
|
|
19
|
+
message = f"{message} | {state_str}"
|
|
20
|
+
|
|
21
|
+
extra_info = LogRecordParser.get_global().parse_extra_info(record)
|
|
22
|
+
if extra_info:
|
|
23
|
+
extra_str = " | ".join(f"{key}={value}" for key, value in extra_info.items())
|
|
24
|
+
message = f"{message} | {extra_str}"
|
|
25
|
+
|
|
26
|
+
return message
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def apply(cls, logger: logging.Logger, fmt: str = fmt_default, datefmt: str = datefmt_default):
|
|
30
|
+
# Check if the logger has any handlers and add a default if none exist
|
|
31
|
+
if not logger.hasHandlers():
|
|
32
|
+
handler = logging.StreamHandler()
|
|
33
|
+
logger.addHandler(handler)
|
|
34
|
+
|
|
35
|
+
# Apply formatter to all handlers
|
|
36
|
+
formatter = cls(fmt=fmt, datefmt=datefmt)
|
|
37
|
+
for h in logger.handlers:
|
|
38
|
+
h.setFormatter(formatter)
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def apply_all(cls, fmt: str = fmt_default, datefmt: str = datefmt_default):
|
|
42
|
+
logger_dict = logging.Logger.manager.loggerDict
|
|
43
|
+
for logger_name in logger_dict:
|
|
44
|
+
logger = logger_dict.get(logger_name)
|
|
45
|
+
if isinstance(logger, logging.Logger):
|
|
46
|
+
ExtraLoggingFormatter.apply(logger, fmt=fmt, datefmt=datefmt)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class NewLineTerminator:
|
|
50
|
+
@classmethod
|
|
51
|
+
def apply(cls, logger: logging.Logger):
|
|
52
|
+
for h in logger.handlers:
|
|
53
|
+
if isinstance(h, logging.StreamHandler):
|
|
54
|
+
h.terminator = "\n"
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def apply_all(cls):
|
|
58
|
+
logger_dict = logging.Logger.manager.loggerDict
|
|
59
|
+
for logger_name in logger_dict:
|
|
60
|
+
logger = logger_dict.get(logger_name)
|
|
61
|
+
if isinstance(logger, logging.Logger):
|
|
62
|
+
NewLineTerminator.apply(logger)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from logging import LogRecord
|
|
3
|
+
|
|
4
|
+
from apppy.logger.format import ExtraLoggingFormatter, datefmt_default, fmt_default
|
|
5
|
+
from apppy.logger.parser import LogRecordParser
|
|
6
|
+
from apppy.logger.storage import LoggingStorage
|
|
7
|
+
|
|
8
|
+
LogRecordParser.set_global()
|
|
9
|
+
LoggingStorage.set_global()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_format_no_extra():
|
|
13
|
+
formatter: ExtraLoggingFormatter = ExtraLoggingFormatter(
|
|
14
|
+
fmt=fmt_default, datefmt=datefmt_default
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
logger = logging.Logger("test_format_no_extra")
|
|
18
|
+
record: LogRecord = logger.makeRecord(
|
|
19
|
+
name="test_format_no_extra",
|
|
20
|
+
level=logging.INFO,
|
|
21
|
+
fn="test_format_no_extra",
|
|
22
|
+
lno=19,
|
|
23
|
+
msg="test_format_no_extra message",
|
|
24
|
+
args=None,
|
|
25
|
+
exc_info=None,
|
|
26
|
+
)
|
|
27
|
+
msg = formatter.format(record)
|
|
28
|
+
assert msg.endswith(
|
|
29
|
+
"| INFO | test_format_no_extra | test_format_no_extra message"
|
|
30
|
+
), f"Unexpected formatted msg: {msg}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_format_with_extra():
|
|
34
|
+
formatter: ExtraLoggingFormatter = ExtraLoggingFormatter(
|
|
35
|
+
fmt=fmt_default, datefmt=datefmt_default
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
logger = logging.Logger("test_format_with_extra")
|
|
39
|
+
record: LogRecord = logger.makeRecord(
|
|
40
|
+
name="test_format_with_extra",
|
|
41
|
+
level=logging.INFO,
|
|
42
|
+
fn="test_format_with_extra",
|
|
43
|
+
lno=19,
|
|
44
|
+
msg="test_format_with_extra message",
|
|
45
|
+
args=None,
|
|
46
|
+
exc_info=None,
|
|
47
|
+
extra={"extra": "value"},
|
|
48
|
+
)
|
|
49
|
+
msg = formatter.format(record)
|
|
50
|
+
assert msg.endswith(
|
|
51
|
+
"| INFO | test_format_with_extra | test_format_with_extra message | extra=value"
|
|
52
|
+
), f"Unexpected formatted msg: {msg}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_format_with_extra_multiple():
|
|
56
|
+
formatter: ExtraLoggingFormatter = ExtraLoggingFormatter(
|
|
57
|
+
fmt=fmt_default, datefmt=datefmt_default
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
logger = logging.Logger("test_format_with_extra_multiple")
|
|
61
|
+
record: LogRecord = logger.makeRecord(
|
|
62
|
+
name="test_format_with_extra_multiple",
|
|
63
|
+
level=logging.INFO,
|
|
64
|
+
fn="test_format_with_extra_multiple",
|
|
65
|
+
lno=19,
|
|
66
|
+
msg="test_format_with_extra_multiple message",
|
|
67
|
+
args=None,
|
|
68
|
+
exc_info=None,
|
|
69
|
+
extra={"extra1": "value1", "extra2": "value2"},
|
|
70
|
+
)
|
|
71
|
+
msg = formatter.format(record)
|
|
72
|
+
assert msg.endswith(
|
|
73
|
+
"| INFO | test_format_with_extra_multiple | test_format_with_extra_multiple message | extra1=value1 | extra2=value2" # noqa: E501
|
|
74
|
+
), f"Unexpected formatted msg: {msg}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_format_with_storage_state():
|
|
78
|
+
formatter: ExtraLoggingFormatter = ExtraLoggingFormatter(
|
|
79
|
+
fmt=fmt_default, datefmt=datefmt_default
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
logger = logging.Logger("test_format_with_storage_state")
|
|
83
|
+
record: LogRecord = logger.makeRecord(
|
|
84
|
+
name="test_format_with_storage_state",
|
|
85
|
+
level=logging.INFO,
|
|
86
|
+
fn="test_format_with_storage_state",
|
|
87
|
+
lno=19,
|
|
88
|
+
msg="test_format_with_storage_state message",
|
|
89
|
+
args=None,
|
|
90
|
+
exc_info=None,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
LoggingStorage.get_global().add_request_id("test_format_with_storage_state_request")
|
|
94
|
+
LoggingStorage.get_global().filter(record)
|
|
95
|
+
msg = formatter.format(record)
|
|
96
|
+
LoggingStorage.get_global().reset()
|
|
97
|
+
|
|
98
|
+
assert msg.endswith(
|
|
99
|
+
"| INFO | test_format_with_storage_state | test_format_with_storage_state message | request_id=test_format_with_storage_state_request" # noqa: E501
|
|
100
|
+
), f"Unexpected formatted msg: {msg}"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_format_with_storage_state_and_extra():
|
|
104
|
+
formatter: ExtraLoggingFormatter = ExtraLoggingFormatter(
|
|
105
|
+
fmt=fmt_default, datefmt=datefmt_default
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
logger = logging.Logger("test_format_with_storage_state_and_extra")
|
|
109
|
+
record: LogRecord = logger.makeRecord(
|
|
110
|
+
name="test_format_with_storage_state_and_extra",
|
|
111
|
+
level=logging.INFO,
|
|
112
|
+
fn="test_format_with_storage_state_and_extra",
|
|
113
|
+
lno=19,
|
|
114
|
+
msg="test_format_with_storage_state_and_extra message",
|
|
115
|
+
args=None,
|
|
116
|
+
exc_info=None,
|
|
117
|
+
extra={"extra": "value"},
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
LoggingStorage.get_global().add_request_id("test_format_with_storage_state_and_extra_request")
|
|
121
|
+
LoggingStorage.get_global().filter(record)
|
|
122
|
+
msg = formatter.format(record)
|
|
123
|
+
LoggingStorage.get_global().reset()
|
|
124
|
+
|
|
125
|
+
assert msg.endswith(
|
|
126
|
+
"| INFO | test_format_with_storage_state_and_extra | test_format_with_storage_state_and_extra message | request_id=test_format_with_storage_state_and_extra_request | extra=value" # noqa: E501
|
|
127
|
+
), f"Unexpected formatted msg: {msg}"
|
apppy/logger/parser.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Any, Optional, cast
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class LogRecordParser:
|
|
6
|
+
_global_instance: Optional["LogRecordParser"] = None
|
|
7
|
+
|
|
8
|
+
def __init__(self):
|
|
9
|
+
# Standard LogRecord fields that should not be considered extra
|
|
10
|
+
self._standard_attrs = list(
|
|
11
|
+
logging.LogRecord("", 0, "", 0, "", (), None, "").__dict__.keys()
|
|
12
|
+
)
|
|
13
|
+
self._standard_attrs.append("asctime")
|
|
14
|
+
self._standard_attrs.append("message")
|
|
15
|
+
# The state field is add via LoggingStorageLoggingFilter
|
|
16
|
+
# and we handle that separately
|
|
17
|
+
self._standard_attrs.append("state")
|
|
18
|
+
|
|
19
|
+
def parse_extra_info(self, log_record: logging.LogRecord) -> dict[str, Any]:
|
|
20
|
+
"""
|
|
21
|
+
Parse extra data from the given log record that is not considered
|
|
22
|
+
standard. That is, which is added at logging time. For example, for
|
|
23
|
+
the log line:
|
|
24
|
+
|
|
25
|
+
self._logger.info("My log message", extra={'user': user_id})
|
|
26
|
+
|
|
27
|
+
This method will return {'user': user_id}
|
|
28
|
+
"""
|
|
29
|
+
extra_info = {
|
|
30
|
+
key: value
|
|
31
|
+
for key, value in log_record.__dict__.items()
|
|
32
|
+
if key not in self._standard_attrs
|
|
33
|
+
}
|
|
34
|
+
return extra_info
|
|
35
|
+
|
|
36
|
+
def parse_state_info(self, log_record: logging.LogRecord) -> dict[str, Any]:
|
|
37
|
+
"""
|
|
38
|
+
Parse state data from the given log record that is added via LoggingStorage.
|
|
39
|
+
"""
|
|
40
|
+
if hasattr(log_record, "state") and isinstance(log_record.state, dict):
|
|
41
|
+
return cast(dict[str, Any], log_record.state)
|
|
42
|
+
|
|
43
|
+
return {}
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def get_global(cls) -> "LogRecordParser":
|
|
47
|
+
if cls._global_instance is None:
|
|
48
|
+
raise RuntimeError("LogRecordParser has not been initialized.")
|
|
49
|
+
return cls._global_instance
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def set_global(cls) -> None:
|
|
53
|
+
if cls._global_instance is None:
|
|
54
|
+
cls._global_instance = LogRecordParser()
|
apppy/logger/storage.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import contextvars
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Any, Literal, Optional
|
|
4
|
+
|
|
5
|
+
LoggingStorageKey = Literal["request_id"]
|
|
6
|
+
|
|
7
|
+
# Define a context variable per key
|
|
8
|
+
_global_logging_vars: dict[str, contextvars.ContextVar[Any | None]] = {
|
|
9
|
+
"request_id": contextvars.ContextVar("request_id", default=None),
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LoggingStorage(logging.Filter):
|
|
14
|
+
_global_instance: Optional["LoggingStorage"] = None
|
|
15
|
+
|
|
16
|
+
def add_request_id(self, request_id: str) -> str:
|
|
17
|
+
_global_logging_vars["request_id"].set(request_id)
|
|
18
|
+
return request_id
|
|
19
|
+
|
|
20
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
21
|
+
stored_state = self.read_all()
|
|
22
|
+
if hasattr(record, "state") and isinstance(record.state, dict):
|
|
23
|
+
record.state.update(stored_state) # type: ignore[invalid-assignment]
|
|
24
|
+
else:
|
|
25
|
+
record.state = stored_state
|
|
26
|
+
|
|
27
|
+
return True
|
|
28
|
+
|
|
29
|
+
def read(self, key: LoggingStorageKey) -> Any | None:
|
|
30
|
+
return _global_logging_vars[key].get()
|
|
31
|
+
|
|
32
|
+
def read_all(self) -> dict[str, Any]:
|
|
33
|
+
return {
|
|
34
|
+
key: value
|
|
35
|
+
for key in _global_logging_vars
|
|
36
|
+
if (value := _global_logging_vars[key].get()) is not None
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
def reset(self):
|
|
40
|
+
# contextvars can't be "deleted", so just reset to None
|
|
41
|
+
for key in _global_logging_vars:
|
|
42
|
+
_global_logging_vars[key].set(None)
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def get_global(cls) -> "LoggingStorage":
|
|
46
|
+
if cls._global_instance is None:
|
|
47
|
+
raise RuntimeError("LoggingStorage has not been initialized.")
|
|
48
|
+
return cls._global_instance
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def set_global(cls) -> None:
|
|
52
|
+
if cls._global_instance is None:
|
|
53
|
+
cls._global_instance = LoggingStorage()
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def apply(logger: logging.Logger):
|
|
57
|
+
logger.addFilter(LoggingStorage.get_global())
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def apply_all():
|
|
61
|
+
logger_dict = logging.Logger.manager.loggerDict
|
|
62
|
+
for logger_name in logger_dict:
|
|
63
|
+
logger = logger_dict.get(logger_name)
|
|
64
|
+
if isinstance(logger, logging.Logger):
|
|
65
|
+
LoggingStorage.apply(logger)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: apppy-logger
|
|
3
|
+
Version: 0.15.1
|
|
4
|
+
Summary: Python logger extensions for server development
|
|
5
|
+
Project-URL: Homepage, https://github.com/spals/apppy
|
|
6
|
+
Author: Tim Kral
|
|
7
|
+
License: MIT
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Requires-Python: >=3.11
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
apppy/logger/__init__.py,sha256=x7m4v_p5bK0etKD8QcLEdiAVZk2z-Vr_ZdW2B2uA1Ls,2658
|
|
2
|
+
apppy/logger/filter.py,sha256=2FTt_ADwc2phniY541_xKX1zR6K8BY3kCFRdJ5FEoBI,509
|
|
3
|
+
apppy/logger/format.py,sha256=JTOJBDzWQzA4axacseibjDAGA0wQspd7byvK-kHH-34,2257
|
|
4
|
+
apppy/logger/format_unit_test.py,sha256=51Jhr8mzaFxvEC0tCdkUP5V-noefDI4fR0jH8FQSnBA,4411
|
|
5
|
+
apppy/logger/parser.py,sha256=QmGm7eaH3lORot8PbAB5Bee_tyHvgC8i80JL_3P4VRc,1898
|
|
6
|
+
apppy/logger/storage.py,sha256=HJ3oYzknBAq7GR7tCrkD18lrP3IEprLMkXJIGIbMirI,2103
|
|
7
|
+
apppy_logger-0.15.1.dist-info/METADATA,sha256=h-5tJ0DAhxIOfmYHXrPEIPbHLVK--AIGllgAqcQKhMg,321
|
|
8
|
+
apppy_logger-0.15.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
9
|
+
apppy_logger-0.15.1.dist-info/RECORD,,
|