Gitlab - Argos ALM by PALO IT

feat: log-call lib

a Python library that provides a decorator to log function calls. It is designed to make debugging and tracking your code execution easier by providing detailed logs of function calls, including their arguments, return values, and any exceptions that might occur.
parent 7ceeeeff
# Created by https://www.toptal.com/developers/gitignore/api/python,visualstudiocode,pycharm
# Edit at https://www.toptal.com/developers/gitignore?templates=python,visualstudiocode,pycharm
### PyCharm ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# SonarLint plugin
.idea/sonarlint/
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
### PyCharm Patch ###
# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
# *.iml
# modules.xml
# .idea/misc.xml
# *.ipr
# Sonarlint plugin
# https://plugins.jetbrains.com/plugin/7973-sonarlint
.idea/**/sonarlint/
# SonarQube Plugin
# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin
.idea/**/sonarIssues.xml
# Markdown Navigator plugin
# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced
.idea/**/markdown-navigator.xml
.idea/**/markdown-navigator-enh.xml
.idea/**/markdown-navigator/
# Cache file creation bug
# See https://youtrack.jetbrains.com/issue/JBR-2257
.idea/$CACHE_FILE$
# CodeStream plugin
# https://plugins.jetbrains.com/plugin/12206-codestream
.idea/codestream.xml
# Azure Toolkit for IntelliJ plugin
# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij
.idea/**/azureSettings.xml
### Python ###
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
### Python Patch ###
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
poetry.toml
# ruff
.ruff_cache/
# LSP config files
pyrightconfig.json
### VisualStudioCode ###
.vscode/
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
### VisualStudioCode Patch ###
# Ignore all local history of files
.history
.ionide
# End of https://www.toptal.com/developers/gitignore/api/python,visualstudiocode,pycharm
\ No newline at end of file
# log-call
`log-call` is a Python library that provides a decorator to log function calls. It is designed to make debugging and tracking your code execution easier by providing detailed logs of function calls, including their arguments, return values, and any exceptions that might occur.
## Features
- **Function Call Logging**: The `@log_call` decorator logs the start and end of a function call, including its arguments and result.
- **Customizable Logging Level**: You can specify the logging level for each function using the `level` parameter in the decorator.
- **Argument and Response Logging**: You can control whether to log the function arguments and result by setting `log_args` and `log_response` parameters in the decorator.
- **Asynchronous Support**: The decorator works seamlessly with both synchronous and asynchronous functions.
- **Exception Logging**: Any exceptions thrown by the decorated function are logged and re-raised.
## Quick Start
First, install the library using pip:
```bash
pip install log-call
```
Then, import the `log_call` decorator in your Python script:
```python
from log_call.log_decorator import log_call
```
## Usage
Decorate your function using `@log_call`:
```python
import logging
from log_call.log_decorator import log_call
@log_call(level=logging.INFO, log_args=True, log_response=True)
def my_function(a, b):
return a + b
```
In the above example, the `my_function` will log its start and end, along with its arguments and result, at the INFO level.
You can customize the logging level, and whether to log arguments and response, for each function individually. If `log_args` and `log_response` are not explicitly set, they default to `True` if the logger is in DEBUG mode. For higher log levels, these values have to be set explicitly.
For asynchronous functions, simply apply the decorator as you would with a synchronous function:
```python
import logging
from log_call.log_decorator import log_call
@log_call(level=logging.INFO, log_args=True, log_response=True)
async def my_async_function(a, b):
return a + b
```
In case of an exception, the error will be logged and then re-raised.
## Notes
- By default, when the logger is in `DEBUG` mode, the `log_args` and `log_response` parameters are set to `True`. For higher log levels, these values have to be set explicitly.
- The logger is taken from the module where the decorated function is defined. If you want to use a different logger, you can pass it as an argument to the decorator.
- The `@log_call` decorator can be used with both synchronous and asynchronous functions.
- For performance reasons, the decorator validates if the log level is enabled for the logger. If the log level is not enabled, the decorator will not log the function call.
- Logging configuration is not handled by the library. You need to configure the logging module in your application.
## Advices
- Prefer using log levels like `INFO` or `DEBUG` for function call logging to avoid excessive logging in production environments.
- Prefer set as False the `log_args` and `log_response` parameters for functions that receive or return large amounts of data to avoid performance issues.
- Take special attention with functions tha receive sensitive data or data that should not be logged, in this case, set as False the `log_args` and `log_response` parameters.
- Take special attention when functions receive large collections
- Prefer pass the argument `_logger` to the decorator to have a better control of the log messages generated by the decorator and yours. By this way you can control the log level independently of the log level of the module where the function is defined.
````python
import logging
from log_call.log_decorator import log_call
logger = logging.getLogger(__name__)
@log_call(level=logging.DEBUG, log_args=False, log_response=False, _logger="decorator_logger")
def my_function(a, b):
logger.log(logging.INFO, f"Summing {a} and {b}")
return a + b
"""
DEBUG : 2024-04-12 14:34:08 --- [decorator_logger ] : Start span:[RjhQ1tdyBfEzYoll] method:[sum_function]
INFO : 2024-04-12 14:34:08 --- [tests.test_log_decorator ] : Summing 5 and 6
DEBUG : 2024-04-12 14:34:08 --- [decorator_logger ] : End span:[RjhQ1tdyBfEzYoll] method:[sum_function]
"""
````
\ No newline at end of file
This diff is collapsed.
[tool.poetry]
name = "log-call"
version = "1.0.0"
description = "log-call is a Python library that provides a decorator to log function calls."
authors = ["Miguel Galindo Rodriguez <mgalindo@palo-it.com>"]
readme = "README.md"
packages = [{include = "log_call", from = "src"}]
[tool.poetry.dependencies]
python = "^3.11"
[tool.poetry.group.test.dependencies]
black = "^24.4.0"
pytest = "^8.1.1"
pytest-cov = "^5.0.0"
pytest-asyncio = "^0.23.6"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.pytest.ini_options]
testpaths = "tests"
filterwarnings = ["error", "ignore:The 'app' shortcut is now deprecated"]
log_cli = false
log_cli_level = "INFO"
log_cli_format = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)"
log_cli_date_format = "%Y-%m-%d %H:%M:%S"
[tool.coverage.run]
branch = true
source = ['src']
[tool.coverage.report]
skip_empty = true
exclude_also = ["def __repr__", "raise AssertionError", "raise NotImplementedError", "@(abc\\.)?abstractmethod", "pass"]
import asyncio
import functools
import logging
import random
import string
start_message = "Start span:[%s] method:[%s]"
args_message = " with args [%s]"
end_message = "End span:[%s] method:[%s]"
result_message = " result [%s]"
exception_message = " exception [%s]"
__alphabet = string.ascii_letters + string.digits
def get_spam(length: int = 16) -> str:
"""
Generate a random string with numbers and letters
:param length: The length of the string
:return: A random string with numbers and letters
"""
return "".join(random.choices(__alphabet, k=length))
def log_error(entry_point, logger, level, spam_id, e):
if logger.isEnabledFor(level):
logger.log(
level, end_message + exception_message, spam_id, entry_point, repr(e)
)
def log_end(entry_point, log_response, is_verbose, logger, level, spam_id, result):
if logger.isEnabledFor(level):
should_log_response = log_response if log_response is not None else is_verbose
if should_log_response:
logger.log(
level, end_message + result_message, spam_id, entry_point, repr(result)
)
else:
logger.log(level, end_message, spam_id, entry_point)
def log_start(entry_point, log_args, is_verbose, logger, level, spam_id, args, kwargs):
if logger.isEnabledFor(level):
should_log_args = log_args if log_args is not None else is_verbose
if should_log_args:
args_repr = [repr(a) for a in args]
kwargs_repr = [f"{k}={v!r}" for k, v in kwargs.items()]
signature = ", ".join(args_repr + kwargs_repr)
logger.log(
level, start_message + args_message, spam_id, entry_point, signature
)
else:
logger.log(level, start_message, spam_id, entry_point)
def log_call(
_func=None,
_logger: logging.Logger | str | None = None,
level: int = logging.DEBUG,
log_args: bool | None = None,
log_response: bool | None = None,
):
"""
Decorator to log the start and end of a function call, including its arguments and result.
:param _logger: logger where the logs will be written, if None the logger of the module will be used
:param _func: the function to decorate
:param level: logging level to use, default is logging.DEBUG
:param log_args: whether to log the function arguments, default is True if the logger is in DEBUG mode
, for higher log levels this value have to be set explicitly
:param log_response: whether to log the function result, default is True if the logger is in DEBUG mode
, for higher log levels this value have to be set explicitly
:return: the decorated function
"""
def log_decorator_info(__func):
def decorate_sync_async(___func):
if _logger is not None:
if isinstance(_logger, str):
logger = logging.getLogger(_logger)
else:
logger = _logger
else:
logger = logging.getLogger(___func.__module__)
spam_id = get_spam()
entry_point = ___func.__qualname__
is_verbose = level == logging.DEBUG
if asyncio.iscoroutinefunction(___func):
async def decorated(*args, **kwargs):
log_start(
entry_point,
log_args,
is_verbose,
logger,
level,
spam_id,
args,
kwargs,
)
try:
result = await ___func(*args, **kwargs)
log_end(
entry_point,
log_response,
is_verbose,
logger,
level,
spam_id,
result,
)
return result
except Exception as e:
log_error(entry_point, logger, level, spam_id, e)
raise e
else:
def decorated(*args, **kwargs):
log_start(
entry_point,
log_args,
is_verbose,
logger,
level,
spam_id,
args,
kwargs,
)
try:
result = ___func(*args, **kwargs)
log_end(
entry_point,
log_response,
is_verbose,
logger,
level,
spam_id,
result,
)
return result
except Exception as e:
log_error(entry_point, logger, level, spam_id, e)
raise e
return functools.wraps(___func)(decorated)
return decorate_sync_async(__func)
if _func is None:
# El decorador se utilizó con paréntesis, por lo que devolvemos el decorador real
return log_decorator_info
else:
# El decorador se utilizó sin paréntesis, por lo que decoramos la función directamente
return log_decorator_info(_func)
import logging
import pytest
from log_call.log_decorator import (
start_message,
log_call,
end_message,
args_message,
result_message,
exception_message,
)
@log_call(level=logging.INFO)
def div(a: float, b: float) -> float:
return a / b
@log_call(level=logging.INFO, log_args=True, log_response=True)
def div2(a: float, b: float) -> float:
return a / b
@log_call(level=logging.INFO, log_args=True, log_response=True)
def do_nothing():
pass
@log_call(level=logging.DEBUG)
def debug_function():
pass
@log_call(level=logging.DEBUG, log_args=False, log_response=False)
def debug_no_args():
pass
@log_call(level=logging.INFO, log_args=True, log_response=True)
async def async_function():
pass
@log_call(level=logging.INFO, log_args=False, log_response=False)
async def async_function_noargs():
pass
@log_call
def no_arguments() -> None:
pass
class Calculator:
@log_call(level=logging.INFO)
def div(self, a: float, b: float) -> float:
return a / b
def test_function_not_log_args(caplog):
caplog.set_level(logging.INFO)
assert div(1, 2) == 0.5
with pytest.raises(ZeroDivisionError):
div(1, 0)
assert caplog.records[0].msg == start_message
assert caplog.records[0].args[1] == "div"
assert caplog.records[1].msg == end_message
assert caplog.records[2].msg == start_message
assert caplog.records[3].msg == end_message + exception_message
def test_function_log_args(caplog):
caplog.set_level(logging.INFO)
assert div2(a=10, b=2) == 5
assert caplog.records[0].msg == start_message + args_message
assert caplog.records[0].args[1] == "div2"
assert "a=10" in caplog.records[0].args[2]
assert "b=2" in caplog.records[0].args[2]
assert caplog.records[1].msg == end_message + result_message
assert "5" in caplog.records[1].args[2]
def test_function_void_with_no_args_log_args_is_none(caplog):
caplog.set_level(logging.INFO)
do_nothing()
assert caplog.records[0].msg == start_message + args_message
assert caplog.records[0].args[1] == "do_nothing"
assert caplog.records[1].msg == end_message + result_message
assert "None" in caplog.records[1].args[2]
def test_debug_function_log_args_as_default(caplog):
caplog.set_level(logging.DEBUG)
debug_function()
assert caplog.records[0].msg == start_message + args_message
assert caplog.records[0].args[1] == "debug_function"
assert caplog.records[1].msg == end_message + result_message
assert "None" in caplog.records[1].args[2]
def test_debug_function_not_log_args(caplog):
caplog.set_level(logging.DEBUG)
debug_no_args()
assert caplog.records[0].msg == start_message
assert caplog.records[0].args[1] == "debug_no_args"
assert caplog.records[1].msg == end_message
def test_not_log_with_low_log_level(caplog):
caplog.set_level(logging.INFO)
debug_no_args()
assert len(caplog.records) == 0
def test_log_class_method(caplog):
caplog.set_level(logging.DEBUG)
calculator = Calculator()
assert calculator.div(10, 2) == 5
assert caplog.records[0].msg == start_message
assert caplog.records[0].args[1] == "Calculator.div"
assert caplog.records[1].msg == end_message
@pytest.mark.asyncio
async def test_log_async_function(caplog):
caplog.set_level(logging.INFO)
await async_function()
assert caplog.records[0].msg == start_message + args_message
assert caplog.records[0].args[1] == "async_function"
assert caplog.records[1].msg == end_message + result_message
assert "None" in caplog.records[1].args[2]
@pytest.mark.asyncio
async def test_log_async_function_no_args(caplog):
caplog.set_level(logging.INFO)
await async_function_noargs()
assert caplog.records[0].msg == start_message
assert caplog.records[0].args[1] == "async_function_noargs"
assert caplog.records[1].msg == end_message
def test_log_no_arguments(caplog):
caplog.set_level(logging.DEBUG)
no_arguments()
assert caplog.records[0].msg == start_message + args_message
assert caplog.records[0].args[1] == "no_arguments"
assert caplog.records[1].msg == end_message + result_message
assert "None" in caplog.records[1].args[2]
logger = logging.getLogger(__name__)
logger_decorator = logging.getLogger("decorator_logger")
@log_call(
level=logging.DEBUG, log_args=False, log_response=False, _logger="decorator_logger"
)
def sum_function(a, b):
logger.log(logging.INFO, f"Summing {a} and {b}")
return a + b
@log_call(
level=logging.DEBUG, log_args=False, log_response=False, _logger=logger_decorator
)
def sum_function2(a, b):
logger.log(logging.INFO, f"Summing {a} and {b}")
return a + b
def test_with_str_logger(caplog):
caplog.set_level(logging.DEBUG)
res = sum_function(5, 6)
assert res == 11
assert caplog.records[0].name == "decorator_logger"
assert caplog.records[1].name == "tests.test_log_decorator"
assert caplog.records[2].name == "decorator_logger"
def test_with_logger_instance_logger(caplog):
caplog.set_level(logging.DEBUG)
res = sum_function2(5, 6)
assert res == 11
assert caplog.records[0].name == "decorator_logger"
assert caplog.records[0].levelname == "DEBUG"
assert caplog.records[1].name == "tests.test_log_decorator"
assert caplog.records[1].levelname == "INFO"
assert caplog.records[2].name == "decorator_logger"
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment