Add support for python logging (#2969)

* adds log module

* enable logging in the app

* adds a getLogger method to AddonManager

* change log level depending on ANKIDEV

* fix undefined module variable

* - fix addons log file path
- remove a breakpoint leftover

set the addons log files under pm.addonFolder()/NNNNNN/user_files/logs/NNNNNN.log

* fix path bug

* move log closing handling into AddonManager deleteAddon/backupUserFiles methods

* logging module level import
fix undefined variable in backupUserFiles

* pretty format log records

* move MediaServer log into logging

* update CONTRIBUTORS

* documentation cleanup

* capture warnings into log messages
fix waitress verbosity

* remove record_factory function

* add get_logger method alias to getLogger in AddonManager
switch to TimedRotatingFileHandler handler
fix minor typo

* set main log level to DEBUG if ANKIDEV is not 0 (or unset)
added two new methods to AddonManager addon_get_logger/addon_toggle_log_level

* add new find_logger_output to AddonManager

* move logs under pm.base

* change log output

* update addonmanager getlogger

* Format imports

* Refactor logging set-up slightly and tweak docstring

* Remove obsolete log closing statements

As logs are no longer stored in user_files, we do not need to close their handlers

* Refactor and try to simplify log module

* Remove demo code

* Refactor and update add-on manager logging API

* Simplify writing unit tests for add-ons that use logging

Loggers are likely to be also employed in non UI code, so it seems like a good idea to decouple them from requiring a running Anki instance to work (thus freeing add-on authors from the need to mock Anki APIs in their tests).

* Fix arguments and drop obsolete inline instructions

Lets add a section on logging to the add-on docs instead

* Drop unnecessary import

* Supply logging basicConfig force option by default

Until we change the module import order and thus ensure that `log` is always evaluated before third-party dependencies have a chance to initialize the root logger, `force` is non-optional.

* Fix formatting and type errors

* Restore mediasrv type ignore comments

* Add note on prefix API stability

* Consistently use addon_from_module in new code

* Use logFolder rather than profileFolder

* Adjust method name for PEP8

* Change loggerDict access path, satisfying pylint

* Drop unused import and use lazy % formatting

* lint fix

* refactor .log_folder -> .addon_logs
store anki.log under logdir

* Fix method name (dae)

* Disable file-based logging in the backend (dae)

I have never found this useful, and it logs nothing by default, so
creating/opening the file is a waste. Removing it also ensures that
addon_logs() is solely used for add-ons.

---------

Co-authored-by: Glutanimate <5459332+glutanimate@users.noreply.github.com>
This commit is contained in:
cav71 2024-02-11 01:41:50 -05:00 committed by GitHub
parent 4ef389b580
commit f4a8f7d9c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 168 additions and 21 deletions

View file

@ -159,6 +159,7 @@ Harvey Randall <harveyrandall2001@gmail.com>
Pedro Lameiras <pedrolameiras@tecnico.ulisboa.pt> Pedro Lameiras <pedrolameiras@tecnico.ulisboa.pt>
Kai Knoblich <kai@FreeBSD.org> Kai Knoblich <kai@FreeBSD.org>
Lucas Scharenbroch <lucasscharenbroch@gmail.com> Lucas Scharenbroch <lucasscharenbroch@gmail.com>
Antonio Cavallo <a.cavallo@cavallinux.eu>
******************** ********************

View file

@ -133,9 +133,9 @@ class Collection(DeprecatedNamesMixin):
sched: V3Scheduler | DummyScheduler sched: V3Scheduler | DummyScheduler
@staticmethod @staticmethod
def initialize_backend_logging(path: str | None = None) -> None: def initialize_backend_logging() -> None:
"""Enable terminal and optional file-based logging. Must be called only once.""" """Enable terminal logging. Must be called only once."""
RustBackend.initialize_logging(path) RustBackend.initialize_logging(None)
def __init__( def __init__(
self, self,

View file

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import logging
import sys import sys
if sys.version_info[0] < 3 or sys.version_info[1] < 9: if sys.version_info[0] < 3 or sys.version_info[1] < 9:
@ -41,6 +42,7 @@ import locale
import os import os
import tempfile import tempfile
import traceback import traceback
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Optional, cast from typing import TYPE_CHECKING, Any, Callable, Optional, cast
import anki.lang import anki.lang
@ -50,6 +52,7 @@ from anki.collection import Collection
from anki.consts import HELP_SITE from anki.consts import HELP_SITE
from anki.utils import checksum, is_lin, is_mac from anki.utils import checksum, is_lin, is_mac
from aqt import gui_hooks from aqt import gui_hooks
from aqt.log import setup_logging
from aqt.qt import * from aqt.qt import *
from aqt.utils import TR, tr from aqt.utils import TR, tr
@ -572,7 +575,6 @@ def _run(argv: Optional[list[str]] = None, exec: bool = True) -> Optional[AnkiAp
pm = None pm = None
try: try:
base_folder = ProfileManager.get_created_base_folder(opts.base) base_folder = ProfileManager.get_created_base_folder(opts.base)
Collection.initialize_backend_logging(str(base_folder / "anki.log"))
# default to specified/system language before getting user's preference so that we can localize some more strings # default to specified/system language before getting user's preference so that we can localize some more strings
lang = anki.lang.get_def_lang(opts.lang) lang = anki.lang.get_def_lang(opts.lang)
@ -581,6 +583,8 @@ def _run(argv: Optional[list[str]] = None, exec: bool = True) -> Optional[AnkiAp
pm = ProfileManager(base_folder) pm = ProfileManager(base_folder)
pmLoadResult = pm.setupMeta() pmLoadResult = pm.setupMeta()
Collection.initialize_backend_logging()
except: except:
# will handle below # will handle below
traceback.print_exc() traceback.print_exc()
@ -623,6 +627,11 @@ def _run(argv: Optional[list[str]] = None, exec: bool = True) -> Optional[AnkiAp
# we've signaled the primary instance, so we should close # we've signaled the primary instance, so we should close
return None return None
setup_logging(
pm.addon_logs(),
level=logging.DEBUG if int(os.getenv("ANKIDEV", "0")) else logging.INFO,
)
if not pm: if not pm:
if i18n_setup: if i18n_setup:
QMessageBox.critical( QMessageBox.critical(

View file

@ -6,6 +6,7 @@ from __future__ import annotations
import html import html
import io import io
import json import json
import logging
import os import os
import re import re
import zipfile import zipfile
@ -33,6 +34,7 @@ from anki.httpclient import HttpClient
from anki.lang import without_unicode_isolation from anki.lang import without_unicode_isolation
from anki.utils import int_version_to_str from anki.utils import int_version_to_str
from aqt import gui_hooks from aqt import gui_hooks
from aqt.log import ADDON_LOGGER_PREFIX, find_addon_logger, get_addon_logs_folder
from aqt.qt import * from aqt.qt import *
from aqt.utils import ( from aqt.utils import (
askUser, askUser,
@ -662,7 +664,11 @@ class AddonManager:
return markdown.markdown(contents, extensions=[md_in_html.makeExtension()]) return markdown.markdown(contents, extensions=[md_in_html.makeExtension()])
def addonFromModule(self, module: str) -> str: def addonFromModule(self, module: str) -> str: # softly deprecated
return module.split(".")[0]
@staticmethod
def addon_from_module(module: str) -> str:
return module.split(".")[0] return module.split(".")[0]
def configAction(self, module: str) -> Callable[[], bool | None]: def configAction(self, module: str) -> Callable[[], bool | None]:
@ -727,8 +733,9 @@ class AddonManager:
def _userFilesBackupPath(self) -> str: def _userFilesBackupPath(self) -> str:
return os.path.join(self.addonsFolder(), "files_backup") return os.path.join(self.addonsFolder(), "files_backup")
def backupUserFiles(self, sid: str) -> None: def backupUserFiles(self, module: str) -> None:
p = self._userFilesPath(sid) p = self._userFilesPath(module)
if os.path.exists(p): if os.path.exists(p):
os.rename(p, self._userFilesBackupPath()) os.rename(p, self._userFilesBackupPath())
@ -752,6 +759,38 @@ class AddonManager:
def getWebExports(self, module: str) -> str: def getWebExports(self, module: str) -> str:
return self._webExports.get(module) return self._webExports.get(module)
# Logging
######################################################################
@classmethod
def get_logger(cls, module: str) -> logging.Logger:
"""Return a logger for the given add-on module.
NOTE: This method is static to allow it to be called outside of a
running Anki instance, e.g. in add-on unit tests.
"""
return logging.getLogger(
f"{ADDON_LOGGER_PREFIX}{cls.addon_from_module(module)}"
)
def has_logger(self, module: str) -> bool:
return find_addon_logger(self.addon_from_module(module)) is not None
def is_debug_logging_enabled(self, module: str) -> bool:
if not (logger := find_addon_logger(self.addon_from_module(module))):
return False
return logger.isEnabledFor(logging.DEBUG)
def toggle_debug_logging(self, module: str, enable: bool) -> None:
if not (logger := find_addon_logger(self.addon_from_module(module))):
return
logger.setLevel(logging.DEBUG if enable else logging.INFO)
def logs_folder(self, module: str) -> Path:
return get_addon_logs_folder(
self.mw.pm.addon_logs(), self.addon_from_module(module)
)
# Add-ons Dialog # Add-ons Dialog
###################################################################### ######################################################################

101
qt/aqt/log.py Normal file
View file

@ -0,0 +1,101 @@
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from __future__ import annotations
import logging
import sys
from logging.handlers import TimedRotatingFileHandler
from pathlib import Path
from typing import Optional, cast
# All loggers with the following prefix will be treated as add-on loggers
#
# To instatiate a logger with this prefix, use aqt.AddonManager.get_logger()
#
# NOTE: Add-ons might also directly instantiate a logger with this prefix, e.g. in
# order to avoid depending on the Anki codebase, so this prefix should not
# be changed.
ADDON_LOGGER_PREFIX = "addon."
# Formatter used for all loggers
FORMATTER = logging.Formatter("%(asctime)s:%(levelname)s:%(name)s: %(message)s")
class AnkiLoggerManager(logging.Manager):
# inspired by: https://github.com/abdnh/ankiutils/blob/master/src/ankiutils/log.py
def __init__(
self,
logs_path: Path | str,
existing_loggers: dict[str, logging.Logger | logging.PlaceHolder],
rootnode: logging.RootLogger,
):
super().__init__(rootnode)
self.loggerDict = existing_loggers
self.logs_path = Path(logs_path)
def getLogger(self, name: str) -> logging.Logger:
if not name.startswith(ADDON_LOGGER_PREFIX) or name in self.loggerDict:
return super().getLogger(name)
# Create a new add-on logger
logger = super().getLogger(name)
module = name.split(ADDON_LOGGER_PREFIX)[1].partition(".")[0]
path = get_addon_logs_folder(self.logs_path, module=module) / f"{module}.log"
path.parent.mkdir(parents=True, exist_ok=True)
# Keep the last 10 days of logs
handler = TimedRotatingFileHandler(
filename=path, when="D", interval=1, backupCount=10, encoding="utf-8"
)
handler.setFormatter(FORMATTER)
logger.addHandler(handler)
return logger
def get_addon_logs_folder(logs_path: Path | str, module: str) -> Path:
return Path(logs_path) / "addons" / module
def find_addon_logger(module: str) -> logging.Logger | None:
return cast(
Optional[logging.Logger],
logging.Logger.manager.loggerDict.get(f"{ADDON_LOGGER_PREFIX}{module}"),
)
def setup_logging(path: Path | str, **kwargs) -> None:
"""
Set up logging for the application.
Configures the root logger to output logs to stdout by default, with custom
handling for add-on logs. The add-on logs are saved to a separate folder and file
for each add-on, under the path provided.
Args:
path (Path): The path where the log files should be stored.
**kwargs: Arbitrary keyword arguments for logging.basicConfig
"""
# Patch root logger manager to handle add-on loggers
logger_manager = AnkiLoggerManager(
path, existing_loggers=logging.Logger.manager.loggerDict, rootnode=logging.root
)
logging.Logger.manager = logger_manager
stdout_handler = logging.StreamHandler(stream=sys.stdout)
stdout_handler.setFormatter(FORMATTER)
logging.basicConfig(handlers=[stdout_handler], force=True, **kwargs)
logging.captureWarnings(True)
# Silence some loggers of external libraries:
silenced_loggers = [
"waitress.queue",
]
for logger in silenced_loggers:
logging.getLogger(logger).setLevel(logging.CRITICAL)
logging.getLogger(logger).propagate = False

View file

@ -10,7 +10,6 @@ import os
import re import re
import sys import sys
import threading import threading
import time
import traceback import traceback
from dataclasses import dataclass from dataclasses import dataclass
from errno import EPROTOTYPE from errno import EPROTOTYPE
@ -43,6 +42,7 @@ from aqt.utils import aqt_data_path, show_warning, tr
# https://forums.ankiweb.net/t/anki-crash-when-using-a-specific-deck/22266 # https://forums.ankiweb.net/t/anki-crash-when-using-a-specific-deck/22266
waitress.wasyncore._DISCONNECTED = waitress.wasyncore._DISCONNECTED.union({EPROTOTYPE}) # type: ignore waitress.wasyncore._DISCONNECTED = waitress.wasyncore._DISCONNECTED.union({EPROTOTYPE}) # type: ignore
logger = logging.getLogger(__name__)
app = flask.Flask(__name__, root_path="/fake") app = flask.Flask(__name__, root_path="/fake")
flask_cors.CORS(app, resources={r"/*": {"origins": "127.0.0.1"}}) flask_cors.CORS(app, resources={r"/*": {"origins": "127.0.0.1"}})
@ -98,11 +98,6 @@ class MediaServer(threading.Thread):
def run(self) -> None: def run(self) -> None:
try: try:
if dev_mode:
# idempotent if logging has already been set up
logging.basicConfig()
logging.getLogger("waitress").setLevel(logging.ERROR)
desired_host = os.getenv("ANKI_API_HOST", "127.0.0.1") desired_host = os.getenv("ANKI_API_HOST", "127.0.0.1")
desired_port = int(os.getenv("ANKI_API_PORT") or 0) desired_port = int(os.getenv("ANKI_API_PORT") or 0)
self.server = create_server( self.server = create_server(
@ -111,10 +106,10 @@ class MediaServer(threading.Thread):
port=desired_port, port=desired_port,
clear_untrusted_proxy_headers=True, clear_untrusted_proxy_headers=True,
) )
if dev_mode: logger.info(
print( "Serving on http://%s:%s",
"Serving on http://%s:%s" self.server.effective_host, # type: ignore[union-attr]
% (self.server.effective_host, self.server.effective_port) # type: ignore self.server.effective_port, # type: ignore[union-attr]
) )
self._ready.set() self._ready.set()
@ -300,8 +295,7 @@ def handle_request(pathin: str) -> Response:
abort(403) abort(403)
req = _extract_request(pathin) req = _extract_request(pathin)
if dev_mode: logger.debug("%s /%s", flask.request.method, pathin)
print(f"{time.time():.3f} {flask.request.method} /{pathin}")
if isinstance(req, NotFound): if isinstance(req, NotFound):
print(req.message) print(req.message)

View file

@ -309,6 +309,9 @@ class ProfileManager:
def collectionPath(self) -> str: def collectionPath(self) -> str:
return os.path.join(self.profileFolder(), "collection.anki2") return os.path.join(self.profileFolder(), "collection.anki2")
def addon_logs(self) -> str:
return self._ensureExists(os.path.join(self.base, "logs"))
# Downgrade # Downgrade
###################################################################### ######################################################################