Skip to content

Commit 29f9488

Browse files
initial code
1 parent 7bdb19a commit 29f9488

12 files changed

Lines changed: 545 additions & 1 deletion

File tree

.fpm

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
-s python
2+
-t deb
3+
--package dist
4+
--force
5+
--log warn
6+
--python-bin python3
7+
--python-package-name-prefix python3
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: Python release
2+
3+
on:
4+
push:
5+
tags:
6+
- "v*.*.*"
7+
8+
jobs:
9+
publish-and-release:
10+
name: Publish and release distributions
11+
12+
runs-on: ubuntu-latest
13+
14+
permissions:
15+
contents: write
16+
discussions: write
17+
18+
env:
19+
GITHUB_TOKEN: ${{ secrets.ACTION_ACCESS_TOKEN }}
20+
21+
outputs:
22+
artifact_id: ${{ steps.publish.outputs.artifact-id }}
23+
artifact_url: ${{ steps.publish.outputs.artifact-url }}
24+
25+
steps:
26+
- name: Check out repository
27+
uses: actions/checkout@v4
28+
- name: Package and publish
29+
uses: EffectiveRange/python-package-github-action@v1
30+
with:
31+
debian-dist-type: 'library'
32+
- name: Release
33+
uses: EffectiveRange/version-release-github-action@v1

.github/workflows/python-test.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: Python test
2+
3+
on:
4+
pull_request:
5+
branches: [ "main" ]
6+
7+
jobs:
8+
build:
9+
name: Build and test
10+
11+
runs-on: ubuntu-latest
12+
13+
permissions:
14+
# Gives the action the necessary permissions for publishing new
15+
# comments in pull requests.
16+
pull-requests: write
17+
contents: read
18+
19+
env:
20+
GITHUB_TOKEN: ${{ secrets.ACTION_ACCESS_TOKEN }}
21+
22+
steps:
23+
- name: Check out repository
24+
uses: actions/checkout@v4
25+
- name: Verify changes
26+
uses: EffectiveRange/python-verify-github-action@v1
27+
with:
28+
add-coverage-comment: 'false'
29+
coverage-threshold: '95'

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,3 +160,8 @@ cython_debug/
160160
# and can be added to the global gitignore or merged into this file. For a more nuclear
161161
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
162162
#.idea/
163+
164+
# IntelliJ project files
165+
*.iml
166+
167+
tests/logs/

README.md

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,103 @@
11
# python-context-logger
2-
Contextual structured logging library for Python
2+
Contextual structured logging library for Python.
3+
4+
Uses [structlog](https://www.structlog.org/en/stable/) to provide structured logging with minimal setup.
5+
6+
## Table of contents
7+
- [Features](#features)
8+
- [Requirements](#requirements)
9+
- [Installation](#installation)
10+
- [Usage](#usage)
11+
- [New in 1.1.0](#new-in-110)
12+
13+
## Features
14+
15+
- Structured logging
16+
- Contextual logging
17+
- Colorized console output
18+
- JSON line file output
19+
- Easy setup
20+
- [New in 1.1.0](#new-in-110) Standard library log messages are also captured, enriched and formatted
21+
22+
Contextual information is added to each structured log message on any thread, including:
23+
- Application name
24+
- Application version
25+
- Hostname
26+
- Logger name
27+
- Log level
28+
- Timestamp
29+
- [New in 1.1.0](#new-in-110) Optional call information (module name, filename, line number, function name, process name, thread name)
30+
31+
Custom fields can be added to each log message
32+
33+
## Requirements
34+
35+
- [Python3](https://www.python.org/downloads/)
36+
- [structlog](https://www.structlog.org/en/stable/)
37+
38+
## Installation
39+
40+
### Install from source root directory
41+
42+
```bash
43+
pip install .
44+
```
45+
46+
### Install from source distribution
47+
48+
1. Create source distribution
49+
```bash
50+
pip setup.py sdist
51+
```
52+
53+
2. Install from distribution file
54+
```bash
55+
pip install dist/python_context_logger-1.0.0.tar.gz
56+
```
57+
58+
3. Install from GitHub repository
59+
```bash
60+
pip install git+https://github.com/EffectiveRange/python-context-logger.git@latest
61+
```
62+
63+
## Usage
64+
Example usage:
65+
```python
66+
from context_logger import get_logger, setup_logging
67+
68+
log = get_logger('ExampleClass')
69+
70+
setup_logging('example-app', 'INFO', 'logs/example.log')
71+
72+
log.info('This is a simple message')
73+
log.error('This is an error message', error_message='Something terrible happened', error_code=1234)
74+
```
75+
Console output (colored):
76+
```
77+
2024-02-16T12:49:41.733384Z [info ] This is a simple message [ExampleClass] app_version=0.0.1 application=example-app hostname=example-host
78+
2024-02-16T12:49:41.734073Z [error ] This is an error message [ExampleClass] app_version=0.0.1 application=example-app error_code=1234 error_message=Something terrible happened hostname=example-host
79+
```
80+
File output (logs/example.log):
81+
```
82+
{"logger": "ExampleClass", "level": "info", "timestamp": "2024-02-16T12:49:41.733384Z", "message": "This is a simple message", "hostname": "example-host", "app_version": "0.0.1", "application": "example-app"}
83+
{"error_message": "Something terrible happened", "error_code": 1234, "logger": "ExampleClass", "level": "error", "timestamp": "2024-02-16T12:49:41.734073Z", "message": "This is an error message", "hostname": "example-host", "app_version": "0.0.1", "application": "example-app"}
84+
```
85+
### New in 1.1.0
86+
Example usage with call information and standard library logging:
87+
```python
88+
import logging
89+
from context_logger import get_logger, setup_logging
90+
91+
setup_logging('example-app', add_call_info=True)
92+
93+
log = get_logger('ExampleClass')
94+
stdlib_log = logging.getLogger('StdLibClass')
95+
96+
log.info('This is a simple message')
97+
stdlib_log.info('This is a simple message')
98+
```
99+
Console output (colored):
100+
```
101+
2024-02-26T14:55:40.320668Z [info ] This is a simple message [ExampleClass] app_version=0.0.1 application=example-app func_name=test_file_logging hostname=example-host lineno=27 module=test_logger pathname=/home/attila/Work/context-logger/tests/test_logger.py process_name=MainProcess thread_name=MainThread
102+
2024-02-26T14:55:40.326201Z [info ] This is a simple message [StdLibClass] app_version=0.0.1 application=example-app func_name=test_file_logging hostname=example-host lineno=28 module=test_logger pathname=/home/attila/Work/context-logger/tests/test_logger.py process_name=MainProcess thread_name=MainThread
103+
```

context_logger/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from .filter import *
2+
from .logger import *

context_logger/filter.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Copyright (C) 2024 Ferenc Nandor Janky
2+
# Copyright (C) 2024 Attila Gombos
3+
# Contact: info@effective-range.com
4+
#
5+
# This library is free software; you can redistribute it and/or
6+
# modify it under the terms of the GNU Lesser General Public
7+
# License as published by the Free Software Foundation; either
8+
# version 2.1 of the License, or (at your option) any later version.
9+
#
10+
# This library is distributed in the hope that it will be useful,
11+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
13+
# Lesser General Public License for more details.
14+
#
15+
# You should have received a copy of the GNU Lesser General Public
16+
# License along with this library; if not, write to the Free Software
17+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301
18+
# USA
19+
import socket
20+
from importlib.metadata import version, PackageNotFoundError
21+
from logging import Filter, LogRecord
22+
23+
24+
class ContextSetupFilter(Filter):
25+
26+
def __init__(self, application_name: str):
27+
super().__init__()
28+
self._application_name = application_name
29+
30+
def filter(self, record: LogRecord) -> bool:
31+
if not isinstance(record.msg, dict):
32+
self._convert_stdlib_record(record)
33+
34+
if isinstance(record.msg, dict):
35+
record.msg['hostname'] = socket.gethostname()
36+
record.msg['application'] = self._application_name
37+
record.msg['app_version'] = self._get_application_version()
38+
39+
if 'process_name' in record.msg:
40+
record.msg['process_name'] = record.processName
41+
42+
return True
43+
44+
def _convert_stdlib_record(self, record: LogRecord) -> None:
45+
if record.args:
46+
record.msg = record.msg % record.args
47+
record.args = ()
48+
49+
record.msg = {'message': record.msg}
50+
51+
def _get_application_version(self) -> str:
52+
try:
53+
return version(self._application_name)
54+
except PackageNotFoundError:
55+
return 'none'

context_logger/logger.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
# Copyright (C) 2024 Ferenc Nandor Janky
2+
# Copyright (C) 2024 Attila Gombos
3+
# Contact: info@effective-range.com
4+
#
5+
# This library is free software; you can redistribute it and/or
6+
# modify it under the terms of the GNU Lesser General Public
7+
# License as published by the Free Software Foundation; either
8+
# version 2.1 of the License, or (at your option) any later version.
9+
#
10+
# This library is distributed in the hope that it will be useful,
11+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
13+
# Lesser General Public License for more details.
14+
#
15+
# You should have received a copy of the GNU Lesser General Public
16+
# License along with this library; if not, write to the Free Software
17+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301
18+
# USA
19+
import logging
20+
import logging.handlers
21+
import os
22+
import sys
23+
import warnings
24+
from logging.handlers import RotatingFileHandler
25+
from typing import Any, Optional
26+
27+
import structlog
28+
from structlog._log_levels import add_log_level
29+
from structlog.dev import ConsoleRenderer
30+
from structlog.processors import JSONRenderer, StackInfoRenderer, TimeStamper, EventRenamer, format_exc_info, \
31+
UnicodeDecoder, CallsiteParameterAdder
32+
from structlog.stdlib import ProcessorFormatter, LoggerFactory, BoundLogger, PositionalArgumentsFormatter, \
33+
add_logger_name
34+
35+
from context_logger import ContextSetupFilter
36+
37+
STRUCTURED_LOGGER = None
38+
39+
40+
def get_logger(logger_name: str) -> Any:
41+
return structlog.get_logger(logger_name)
42+
43+
44+
def setup_logging(application_name: str, log_level: str = 'INFO',
45+
log_file_path: Optional[str] = None, max_bytes: int = 1024 * 1024, backup_count: int = 5,
46+
add_call_info: bool = False, message_field: str = 'message') -> None:
47+
global STRUCTURED_LOGGER
48+
49+
if STRUCTURED_LOGGER is not None:
50+
warnings.warn('Logging has already been set up, overwriting existing configuration.', UserWarning)
51+
52+
STRUCTURED_LOGGER = StructuredLogger(application_name, log_level, log_file_path, max_bytes, backup_count,
53+
add_call_info, message_field)
54+
55+
56+
class StructuredLogger(object):
57+
58+
def __init__(self, application_name: str, log_level: str, log_file_path: Optional[str], max_bytes: int,
59+
backup_count: int, add_call_info: bool, message_field: str) -> None:
60+
self._application_name = application_name
61+
self._log_level = logging.getLevelName(log_level.upper())
62+
self._max_bytes = max_bytes
63+
self._backup_count = backup_count
64+
self._message_field = message_field
65+
self._add_call_info = add_call_info
66+
67+
self._shared_processors: Any = [
68+
add_log_level,
69+
add_logger_name,
70+
PositionalArgumentsFormatter(),
71+
StackInfoRenderer(),
72+
TimeStamper(fmt='iso'),
73+
format_exc_info,
74+
UnicodeDecoder()
75+
]
76+
77+
if self._add_call_info:
78+
self._add_call_info_processor()
79+
80+
self._shared_processors.append(EventRenamer(self._message_field))
81+
82+
root = logging.getLogger()
83+
root.setLevel(self._log_level)
84+
85+
self._clear_handlers(root)
86+
87+
console_handler = self._create_console_handler()
88+
root.addHandler(console_handler)
89+
90+
if log_file_path is not None:
91+
file_handler = self._create_file_handler(log_file_path)
92+
root.addHandler(file_handler)
93+
94+
structlog.configure(
95+
processors=self._shared_processors + [ProcessorFormatter.wrap_for_formatter],
96+
logger_factory=LoggerFactory(),
97+
wrapper_class=BoundLogger,
98+
cache_logger_on_first_use=True
99+
)
100+
101+
def _add_call_info_processor(self) -> None:
102+
self._shared_processors.append(CallsiteParameterAdder(
103+
{
104+
structlog.processors.CallsiteParameter.MODULE,
105+
structlog.processors.CallsiteParameter.PATHNAME,
106+
structlog.processors.CallsiteParameter.FUNC_NAME,
107+
structlog.processors.CallsiteParameter.LINENO,
108+
structlog.processors.CallsiteParameter.PROCESS_NAME,
109+
structlog.processors.CallsiteParameter.THREAD_NAME
110+
}
111+
))
112+
113+
def _clear_handlers(self, root: logging.Logger) -> None:
114+
for handler in root.handlers:
115+
if isinstance(handler, logging.StreamHandler) or isinstance(handler, RotatingFileHandler):
116+
root.removeHandler(handler)
117+
118+
def _create_console_handler(self) -> logging.Handler:
119+
formatter = ProcessorFormatter(
120+
foreign_pre_chain=self._shared_processors + [self._enrich_stdlib_log],
121+
processors=[ProcessorFormatter.remove_processors_meta, ConsoleRenderer(event_key=self._message_field)]
122+
)
123+
124+
handler = logging.StreamHandler(sys.stdout)
125+
handler.setFormatter(formatter)
126+
handler.addFilter(ContextSetupFilter(self._application_name))
127+
128+
return handler
129+
130+
def _create_file_handler(self, log_file_path: str) -> RotatingFileHandler:
131+
formatter = ProcessorFormatter(
132+
foreign_pre_chain=self._shared_processors + [self._enrich_stdlib_log],
133+
processors=[ProcessorFormatter.remove_processors_meta, JSONRenderer()]
134+
)
135+
136+
self._ensure_directory_exists(log_file_path)
137+
138+
handler = RotatingFileHandler(log_file_path, maxBytes=self._max_bytes, backupCount=self._backup_count)
139+
handler.setFormatter(formatter)
140+
handler.addFilter(ContextSetupFilter(self._application_name))
141+
142+
return handler
143+
144+
def _ensure_directory_exists(self, log_file_path: str) -> None:
145+
directory = os.path.dirname(log_file_path)
146+
147+
if not os.path.exists(directory):
148+
os.makedirs(directory)
149+
150+
def _enrich_stdlib_log(self, _: logging.Logger, __: str, event_dict: dict[Any, Any]) -> dict[Any, Any]:
151+
event_dict.update(event_dict['_record'].msg)
152+
153+
return event_dict

context_logger/py.typed

Whitespace-only changes.

0 commit comments

Comments
 (0)