From 30cff16ccca0dc41469d1d35567a997bca05802a Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Thu, 5 Sep 2024 16:30:32 +0300 Subject: [PATCH 1/4] NextcloudApp: new `setup_nextcloud_logging` function for transparent logging Signed-off-by: Alexander Piskun --- CHANGELOG.md | 6 ++++- nc_py_api/ex_app/__init__.py | 1 + nc_py_api/ex_app/logging.py | 47 ++++++++++++++++++++++++++++++++++++ nc_py_api/nextcloud.py | 22 +++++++++-------- 4 files changed, 65 insertions(+), 11 deletions(-) create mode 100644 nc_py_api/ex_app/logging.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e5a0565..1afcbd91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,13 @@ All notable changes to this project will be documented in this file. ## [0.17.1 - 2024-09-06] +### Added + +- NextcloudApp: `setup_nextcloud_logging` function to support transparently sending logs to Nextcloud. #294 + ### Fixed -- NextcloudApp: `nc.log` now suppresses all exceptions to safe call it anywhere in your app. +- NextcloudApp: `nc.log` now suppresses all exceptions to safe call it anywhere in your app. #293 ## [0.17.0 - 2024-09-05] diff --git a/nc_py_api/ex_app/__init__.py b/nc_py_api/ex_app/__init__.py index 8f9a1b2f..84104cd4 100644 --- a/nc_py_api/ex_app/__init__.py +++ b/nc_py_api/ex_app/__init__.py @@ -10,6 +10,7 @@ set_handlers, talk_bot_msg, ) +from .logging import setup_nextcloud_logging from .misc import ( get_computation_device, get_model_path, diff --git a/nc_py_api/ex_app/logging.py b/nc_py_api/ex_app/logging.py new file mode 100644 index 00000000..cd245767 --- /dev/null +++ b/nc_py_api/ex_app/logging.py @@ -0,0 +1,47 @@ +"""Transparent logging support to store logs in the nextcloud.log.""" + +import logging + +from ..nextcloud import NextcloudApp +from .defs import LogLvl + + +def _python_loglvl_to_nextcloud(levelno: int) -> LogLvl: + if levelno in (logging.NOTSET, logging.DEBUG): + return LogLvl.DEBUG + if levelno == logging.INFO: + return LogLvl.INFO + if levelno == logging.WARNING: + return LogLvl.WARNING + if levelno == logging.ERROR: + return LogLvl.ERROR + return LogLvl.FATAL + + +class _NextcloudStorageHandler(logging.Handler): + def __init__(self): + super().__init__() + self.lock_flag = False + + def emit(self, record): + if self.lock_flag: + return + + try: + self.lock_flag = True + log_entry = self.format(record) + log_level = record.levelno + NextcloudApp().log(_python_loglvl_to_nextcloud(log_level), log_entry, fast_send=True) + except Exception: # noqa pylint: disable=broad-exception-caught + self.handleError(record) + finally: + self.lock_flag = False + + +def setup_nextcloud_logging(logger_name: str | None = None, logging_level: int = logging.DEBUG): + """Function to easily send all or selected log entries to Nextcloud.""" + logger = logging.getLogger(logger_name) + nextcloud_handler = _NextcloudStorageHandler() + nextcloud_handler.setLevel(logging_level) + logger.addHandler(nextcloud_handler) + return nextcloud_handler diff --git a/nc_py_api/nextcloud.py b/nc_py_api/nextcloud.py index a4f9baa7..54a46a5f 100644 --- a/nc_py_api/nextcloud.py +++ b/nc_py_api/nextcloud.py @@ -348,15 +348,16 @@ def enabled_state(self) -> bool: return bool(self._session.ocs("GET", "/ocs/v1.php/apps/app_api/ex-app/state")) return False - def log(self, log_lvl: LogLvl, content: str) -> None: + def log(self, log_lvl: LogLvl, content: str, fast_send: bool = False) -> None: """Writes log to the Nextcloud log file.""" - if self.check_capabilities("app_api"): - return int_log_lvl = int(log_lvl) if int_log_lvl < 0 or int_log_lvl > 4: raise ValueError("Invalid `log_lvl` value") - if int_log_lvl < self.capabilities["app_api"].get("loglevel", 0): - return + if not fast_send: + if self.check_capabilities("app_api"): + return + if int_log_lvl < self.capabilities["app_api"].get("loglevel", 0): + return with contextlib.suppress(Exception): self._session.ocs("POST", f"{self._session.ae_url}/log", json={"level": int_log_lvl, "message": content}) @@ -482,15 +483,16 @@ async def enabled_state(self) -> bool: return bool(await self._session.ocs("GET", "/ocs/v1.php/apps/app_api/ex-app/state")) return False - async def log(self, log_lvl: LogLvl, content: str) -> None: + async def log(self, log_lvl: LogLvl, content: str, fast_send: bool = False) -> None: """Writes log to the Nextcloud log file.""" - if await self.check_capabilities("app_api"): - return int_log_lvl = int(log_lvl) if int_log_lvl < 0 or int_log_lvl > 4: raise ValueError("Invalid `log_lvl` value") - if int_log_lvl < (await self.capabilities)["app_api"].get("loglevel", 0): - return + if not fast_send: + if await self.check_capabilities("app_api"): + return + if int_log_lvl < (await self.capabilities)["app_api"].get("loglevel", 0): + return with contextlib.suppress(Exception): await self._session.ocs( "POST", f"{self._session.ae_url}/log", json={"level": int_log_lvl, "message": content} From 05cf4aa59fb12f4e786b94fcd36c2390c83091d6 Mon Sep 17 00:00:00 2001 From: Alexander Piskun <13381981+bigcat88@users.noreply.github.com> Date: Thu, 5 Sep 2024 18:59:55 +0300 Subject: [PATCH 2/4] we use threading.local to store the recursion protection flag (for Python 3.13+) Signed-off-by: Alexander Piskun --- nc_py_api/ex_app/logging.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/nc_py_api/ex_app/logging.py b/nc_py_api/ex_app/logging.py index cd245767..b0056658 100644 --- a/nc_py_api/ex_app/logging.py +++ b/nc_py_api/ex_app/logging.py @@ -1,6 +1,7 @@ """Transparent logging support to store logs in the nextcloud.log.""" import logging +import threading from ..nextcloud import NextcloudApp from .defs import LogLvl @@ -18,30 +19,29 @@ def _python_loglvl_to_nextcloud(levelno: int) -> LogLvl: return LogLvl.FATAL -class _NextcloudStorageHandler(logging.Handler): +class _NextcloudLogsHandler(logging.Handler): def __init__(self): super().__init__() - self.lock_flag = False def emit(self, record): - if self.lock_flag: + if threading.local().__dict__.get("nc_py_api.loghandler", False): return try: - self.lock_flag = True + threading.local().__dict__["nc_py_api.loghandler"] = True log_entry = self.format(record) log_level = record.levelno NextcloudApp().log(_python_loglvl_to_nextcloud(log_level), log_entry, fast_send=True) except Exception: # noqa pylint: disable=broad-exception-caught self.handleError(record) finally: - self.lock_flag = False + threading.local().__dict__["nc_py_api.loghandler"] = False def setup_nextcloud_logging(logger_name: str | None = None, logging_level: int = logging.DEBUG): """Function to easily send all or selected log entries to Nextcloud.""" logger = logging.getLogger(logger_name) - nextcloud_handler = _NextcloudStorageHandler() + nextcloud_handler = _NextcloudLogsHandler() nextcloud_handler.setLevel(logging_level) logger.addHandler(nextcloud_handler) return nextcloud_handler From b0545af415d67480aa1237799cce38e196fc5f3c Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Thu, 5 Sep 2024 19:20:45 +0300 Subject: [PATCH 3/4] added small test Signed-off-by: Alexander Piskun --- nc_py_api/ex_app/logging.py | 21 +++++++++------------ tests/actual_tests/logs_test.py | 14 +++++++++++++- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/nc_py_api/ex_app/logging.py b/nc_py_api/ex_app/logging.py index b0056658..e24de930 100644 --- a/nc_py_api/ex_app/logging.py +++ b/nc_py_api/ex_app/logging.py @@ -6,17 +6,14 @@ from ..nextcloud import NextcloudApp from .defs import LogLvl - -def _python_loglvl_to_nextcloud(levelno: int) -> LogLvl: - if levelno in (logging.NOTSET, logging.DEBUG): - return LogLvl.DEBUG - if levelno == logging.INFO: - return LogLvl.INFO - if levelno == logging.WARNING: - return LogLvl.WARNING - if levelno == logging.ERROR: - return LogLvl.ERROR - return LogLvl.FATAL +LOGLVL_MAP = { + logging.NOTSET: LogLvl.DEBUG, + logging.DEBUG: LogLvl.DEBUG, + logging.INFO: LogLvl.INFO, + logging.WARNING: LogLvl.WARNING, + logging.ERROR: LogLvl.ERROR, + logging.CRITICAL: LogLvl.FATAL, +} class _NextcloudLogsHandler(logging.Handler): @@ -31,7 +28,7 @@ def emit(self, record): threading.local().__dict__["nc_py_api.loghandler"] = True log_entry = self.format(record) log_level = record.levelno - NextcloudApp().log(_python_loglvl_to_nextcloud(log_level), log_entry, fast_send=True) + NextcloudApp().log(LOGLVL_MAP.get(log_level, LogLvl.FATAL), log_entry, fast_send=True) except Exception: # noqa pylint: disable=broad-exception-caught self.handleError(record) finally: diff --git a/tests/actual_tests/logs_test.py b/tests/actual_tests/logs_test.py index 74d35a2d..75a7e9a3 100644 --- a/tests/actual_tests/logs_test.py +++ b/tests/actual_tests/logs_test.py @@ -1,9 +1,10 @@ +import logging from copy import deepcopy from unittest import mock import pytest -from nc_py_api.ex_app import LogLvl +from nc_py_api.ex_app import LogLvl, setup_nextcloud_logging def test_loglvl_values(): @@ -113,3 +114,14 @@ async def test_log_without_app_api_async(anc_app): ): await anc_app.log(log_lvl, "will not be sent") ocs.assert_not_called() + + +def test_logging(nc_app): + log_handler = setup_nextcloud_logging("my_logger") + logger = logging.getLogger("my_logger") + logger.fatal("testing logging.fatal") + try: + a = b # noqa + except Exception: # noqa + logger.exception("testing logger.exception") + logger.removeHandler(log_handler) From 7cb959ba2e1d681d9c4759113eb346d50204c705 Mon Sep 17 00:00:00 2001 From: Alexander Piskun <13381981+bigcat88@users.noreply.github.com> Date: Thu, 5 Sep 2024 21:01:34 +0300 Subject: [PATCH 4/4] refactored `threading.local()` usage (new test detected error) Signed-off-by: Alexander Piskun --- nc_py_api/ex_app/logging.py | 8 +++++--- tests/actual_tests/logs_test.py | 9 +++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/nc_py_api/ex_app/logging.py b/nc_py_api/ex_app/logging.py index e24de930..1ff16893 100644 --- a/nc_py_api/ex_app/logging.py +++ b/nc_py_api/ex_app/logging.py @@ -15,24 +15,26 @@ logging.CRITICAL: LogLvl.FATAL, } +THREAD_LOCAL = threading.local() + class _NextcloudLogsHandler(logging.Handler): def __init__(self): super().__init__() def emit(self, record): - if threading.local().__dict__.get("nc_py_api.loghandler", False): + if THREAD_LOCAL.__dict__.get("nc_py_api.loghandler", False): return try: - threading.local().__dict__["nc_py_api.loghandler"] = True + THREAD_LOCAL.__dict__["nc_py_api.loghandler"] = True log_entry = self.format(record) log_level = record.levelno NextcloudApp().log(LOGLVL_MAP.get(log_level, LogLvl.FATAL), log_entry, fast_send=True) except Exception: # noqa pylint: disable=broad-exception-caught self.handleError(record) finally: - threading.local().__dict__["nc_py_api.loghandler"] = False + THREAD_LOCAL.__dict__["nc_py_api.loghandler"] = False def setup_nextcloud_logging(logger_name: str | None = None, logging_level: int = logging.DEBUG): diff --git a/tests/actual_tests/logs_test.py b/tests/actual_tests/logs_test.py index 75a7e9a3..dc1ae6af 100644 --- a/tests/actual_tests/logs_test.py +++ b/tests/actual_tests/logs_test.py @@ -125,3 +125,12 @@ def test_logging(nc_app): except Exception: # noqa logger.exception("testing logger.exception") logger.removeHandler(log_handler) + + +def test_recursive_logging(nc_app): + logging.getLogger("httpx").setLevel(logging.DEBUG) + log_handler = setup_nextcloud_logging() + logger = logging.getLogger() + logger.fatal("testing logging.fatal") + logger.removeHandler(log_handler) + logging.getLogger("httpx").setLevel(logging.ERROR)