fix: logger: improve typing, globals and tests

This commit is contained in:
mathieui 2021-04-03 14:01:34 +02:00
parent f56811d546
commit dafe5b9e24
3 changed files with 177 additions and 61 deletions

View file

@ -13,19 +13,17 @@ import mmap
import re import re
from typing import List, Dict, Optional, IO, Any, Union from typing import List, Dict, Optional, IO, Any, Union
from datetime import datetime from datetime import datetime
from pathlib import Path
from poezio import common from poezio import common
from poezio.config import config from poezio.config import config
from poezio.xhtml import clean_text from poezio.xhtml import clean_text
from poezio.theming import dump_tuple, get_theme
from poezio.ui.types import Message, BaseMessage, LoggableTrait from poezio.ui.types import Message, BaseMessage, LoggableTrait
import logging import logging
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
from poezio.config import LOG_DIR as log_dir
MESSAGE_LOG_RE = re.compile(r'^MR (\d{4})(\d{2})(\d{2})T' MESSAGE_LOG_RE = re.compile(r'^MR (\d{4})(\d{2})(\d{2})T'
r'(\d{2}):(\d{2}):(\d{2})Z ' r'(\d{2}):(\d{2}):(\d{2})Z '
r'(\d+) <([^ ]+)>  (.*)$') r'(\d+) <([^ ]+)>  (.*)$')
@ -35,8 +33,13 @@ INFO_LOG_RE = re.compile(r'^MI (\d{4})(\d{2})(\d{2})T'
class LogItem: class LogItem:
def __init__(self, year, month, day, hour, minute, second, nb_lines, time: datetime
message): nb_lines: int
text: str
def __init__(self, year: str, month: str, day: str, hour: str, minute: str,
second: str, nb_lines: str,
message: str):
self.time = datetime( self.time = datetime(
int(year), int(month), int(day), int(hour), int(minute), int(year), int(month), int(day), int(hour), int(minute),
int(second)) int(second))
@ -50,14 +53,23 @@ class LogInfo(LogItem):
class LogMessage(LogItem): class LogMessage(LogItem):
def __init__(self, year, month, day, hour, minute, seconds, nb_lines, nick, nick: str
message):
def __init__(self, year: str, month: str, day: str, hour: str, minute: str,
seconds: str, nb_lines: str, nick: str,
message: str):
LogItem.__init__(self, year, month, day, hour, minute, seconds, LogItem.__init__(self, year, month, day, hour, minute, seconds,
nb_lines, message) nb_lines, message)
self.nick = nick self.nick = nick
def parse_log_line(msg: str, jid: str) -> Optional[LogItem]: def parse_log_line(msg: str, jid: str) -> Optional[LogItem]:
"""Parse a log line.
:param msg: The message ligne
:param jid: jid (for error logging)
:returns: The LogItem or None on error
"""
match = re.match(MESSAGE_LOG_RE, msg) match = re.match(MESSAGE_LOG_RE, msg)
if match: if match:
return LogMessage(*match.groups()) return LogMessage(*match.groups())
@ -73,60 +85,83 @@ class Logger:
Appends things to files. Error/information/warning logs Appends things to files. Error/information/warning logs
and also log the conversations to logfiles and also log the conversations to logfiles
""" """
_roster_logfile: Optional[IO[str]]
log_dir: Path
_fds: Dict[str, IO[str]]
def __init__(self): def __init__(self):
self._roster_logfile = None # Optional[IO[Any]] self.log_dir = Path()
self._roster_logfile = None
# a dict of 'groupchatname': file-object (opened) # a dict of 'groupchatname': file-object (opened)
self._fds: Dict[str, IO[Any]] = {} self._fds = {}
def __del__(self): def __del__(self):
"""Close all fds on exit"""
for opened_file in self._fds.values(): for opened_file in self._fds.values():
if opened_file: if opened_file:
try: try:
opened_file.close() opened_file.close()
except: # Can't close? too bad except Exception: # Can't close? too bad
pass pass
try:
self._roster_logfile.close()
except Exception:
pass
def close(self, jid) -> None: def close(self, jid: str) -> None:
"""Close the log file for a JID."""
jid = str(jid).replace('/', '\\') jid = str(jid).replace('/', '\\')
if jid in self._fds: if jid in self._fds:
self._fds[jid].close() self._fds[jid].close()
log.debug('Log file for %s closed.', jid) log.debug('Log file for %s closed.', jid)
del self._fds[jid] del self._fds[jid]
return None
def reload_all(self) -> None: def reload_all(self) -> None:
"""Close and reload all the file handles (on SIGHUP)""" """Close and reload all the file handles (on SIGHUP)"""
for opened_file in self._fds.values(): not_closed = set()
for key, opened_file in self._fds.items():
if opened_file: if opened_file:
opened_file.close() try:
opened_file.close()
except Exception:
not_closed.add(key)
if self._roster_logfile:
try:
self._roster_logfile.close()
except Exception:
not_closed.add('roster')
log.debug('All log file handles closed') log.debug('All log file handles closed')
if not_closed:
log.error('Unable to close log files for: %s', not_closed)
for room in self._fds: for room in self._fds:
self._check_and_create_log_dir(room) self._check_and_create_log_dir(room)
log.debug('Log handle for %s re-created', room) log.debug('Log handle for %s re-created', room)
return None
def _check_and_create_log_dir(self, room: str, def _check_and_create_log_dir(self, jid: str,
open_fd: bool = True) -> Optional[IO[Any]]: open_fd: bool = True) -> Optional[IO[str]]:
""" """
Check that the directory where we want to log the messages Check that the directory where we want to log the messages
exists. if not, create it exists. if not, create it
:param jid: JID of the file to open after creating the dir
:param open_fd: if the file should be opened after creating the dir
:returns: the opened fd or None
""" """
if not config.get_by_tabname('use_log', room): if not config.get_by_tabname('use_log', jid):
return None return None
try: try:
log_dir.mkdir(parents=True, exist_ok=True) self.log_dir.mkdir(parents=True, exist_ok=True)
except OSError as e: except OSError:
log.error('Unable to create the log dir', exc_info=True) log.error('Unable to create the log dir', exc_info=True)
except: except Exception:
log.error('Unable to create the log dir', exc_info=True) log.error('Unable to create the log dir', exc_info=True)
return None return None
if not open_fd: if not open_fd:
return None return None
filename = log_dir / room filename = self.log_dir / jid
try: try:
fd = filename.open('a', encoding='utf-8') fd = filename.open('a', encoding='utf-8')
self._fds[room] = fd self._fds[jid] = fd
return fd return fd
except IOError: except IOError:
log.error( log.error(
@ -137,11 +172,11 @@ class Logger:
jid: str, jid: str,
msg: Union[BaseMessage, Message]) -> bool: msg: Union[BaseMessage, Message]) -> bool:
""" """
log the message in the appropriate jid's file Log the message in the appropriate file
type:
0 = Dont log :param jid: JID of the entity for which to log the message
1 = Message :param msg: Message to log
2 = Status/whatever :returns: True if no error was encountered
""" """
if not config.get_by_tabname('use_log', jid): if not config.get_by_tabname('use_log', jid):
return True return True
@ -149,12 +184,12 @@ class Logger:
return True return True
date = msg.time date = msg.time
txt = msg.txt txt = msg.txt
typ = 2
nick = '' nick = ''
typ = 'MI'
if isinstance(msg, Message): if isinstance(msg, Message):
nick = msg.nickname or '' nick = msg.nickname or ''
typ = 1 typ = 'MR'
logged_msg = build_log_message(nick, txt, date=date, typ=typ) logged_msg = build_log_message(nick, txt, date=date, prefix=typ)
if not logged_msg: if not logged_msg:
return True return True
jid = str(jid).replace('/', '\\') jid = str(jid).replace('/', '\\')
@ -165,7 +200,7 @@ class Logger:
if option_fd is None: if option_fd is None:
return True return True
fd = option_fd fd = option_fd
filename = log_dir / jid filename = self.log_dir / jid
try: try:
fd.write(logged_msg) fd.write(logged_msg)
except OSError: except OSError:
@ -188,11 +223,15 @@ class Logger:
def log_roster_change(self, jid: str, message: str) -> bool: def log_roster_change(self, jid: str, message: str) -> bool:
""" """
Log a roster change Log a roster change
:param jid: jid to log the change for
:param message: message to log
:returns: True if no error happened
""" """
if not config.get_by_tabname('use_log', jid): if not config.get_by_tabname('use_log', jid):
return True return True
self._check_and_create_log_dir('', open_fd=False) self._check_and_create_log_dir('', open_fd=False)
filename = log_dir / 'roster.log' filename = self.log_dir / 'roster.log'
if not self._roster_logfile: if not self._roster_logfile:
try: try:
self._roster_logfile = filename.open('a', encoding='utf-8') self._roster_logfile = filename.open('a', encoding='utf-8')
@ -213,7 +252,7 @@ class Logger:
for line in lines: for line in lines:
self._roster_logfile.write(' %s\n' % line) self._roster_logfile.write(' %s\n' % line)
self._roster_logfile.flush() self._roster_logfile.flush()
except: except Exception:
log.error( log.error(
'Unable to write in the log file (%s)', 'Unable to write in the log file (%s)',
filename, filename,
@ -225,21 +264,19 @@ class Logger:
def build_log_message(nick: str, def build_log_message(nick: str,
msg: str, msg: str,
date: Optional[datetime] = None, date: Optional[datetime] = None,
typ: int = 1) -> str: prefix: str = 'MI') -> str:
""" """
Create a log message from a nick, a message, optionally a date and type Create a log message from a nick, a message, optionally a date and type
message types:
0 = Dont log
1 = Message
2 = Status/whatever
"""
if not typ:
return ''
:param nick: nickname to log
:param msg: text of the message
:param date: date of the message
:param prefix: MI (info) or MR (message)
:returns: The log line(s)
"""
msg = clean_text(msg) msg = clean_text(msg)
time = common.get_utc_time() if date is None else common.get_utc_time(date) time = common.get_utc_time() if date is None else common.get_utc_time(date)
str_time = time.strftime('%Y%m%dT%H:%M:%SZ') str_time = time.strftime('%Y%m%dT%H:%M:%SZ')
prefix = 'MR' if typ == 1 else 'MI'
lines = msg.split('\n') lines = msg.split('\n')
first_line = lines.pop(0) first_line = lines.pop(0)
nb_lines = str(len(lines)).zfill(3) nb_lines = str(len(lines)).zfill(3)
@ -254,7 +291,11 @@ def build_log_message(nick: str,
def _get_lines_from_fd(fd: IO[Any], nb: int = 10) -> List[str]: def _get_lines_from_fd(fd: IO[Any], nb: int = 10) -> List[str]:
""" """
Get the last log lines from a fileno Get the last log lines from a fileno with mmap
:param fd: File descriptor on the log file
:param nb: number of messages to fetch
:returns: A list of message lines
""" """
with mmap.mmap(fd.fileno(), 0, prot=mmap.PROT_READ) as m: with mmap.mmap(fd.fileno(), 0, prot=mmap.PROT_READ) as m:
# start of messages begin with MI or MR, after a \n # start of messages begin with MI or MR, after a \n
@ -271,9 +312,12 @@ def _get_lines_from_fd(fd: IO[Any], nb: int = 10) -> List[str]:
def parse_log_lines(lines: List[str], jid: str) -> List[Dict[str, Any]]: def parse_log_lines(lines: List[str], jid: str) -> List[Dict[str, Any]]:
""" """
Parse raw log lines into poezio log objects Parse raw log lines into poezio log objects
:param lines: Message lines
:param jid: jid (for error logging)
:return: a list of dicts containing message info
""" """
messages = [] messages = []
color = '\x19%s}' % dump_tuple(get_theme().COLOR_LOG_MSG)
# now convert that data into actual Message objects # now convert that data into actual Message objects
idx = 0 idx = 0
@ -294,10 +338,10 @@ def parse_log_lines(lines: List[str], jid: str) -> List[Dict[str, Any]]:
} }
size = log_item.nb_lines size = log_item.nb_lines
if isinstance(log_item, LogInfo): if isinstance(log_item, LogInfo):
message_lines.append(color + log_item.text) message_lines.append(log_item.text)
elif isinstance(log_item, LogMessage): elif isinstance(log_item, LogMessage):
message['nickname'] = log_item.nick message['nickname'] = log_item.nick
message_lines.append(color + log_item.text) message_lines.append(log_item.text)
while size != 0 and idx < len(lines): while size != 0 and idx < len(lines):
message_lines.append(lines[idx][1:]) message_lines.append(lines[idx][1:])
size -= 1 size -= 1
@ -307,10 +351,4 @@ def parse_log_lines(lines: List[str], jid: str) -> List[Dict[str, Any]]:
return messages return messages
def create_logger() -> None:
"Create the global logger object"
global logger
logger = Logger()
logger = Logger() logger = Logger()

View file

@ -100,8 +100,8 @@ def main():
from poezio import theming from poezio import theming
theming.update_themes_dir() theming.update_themes_dir()
from poezio import logger from poezio.logger import logger
logger.create_logger() logger.log_dir = config.LOG_DIR
from poezio import roster from poezio import roster
roster.roster.reset() roster.roster.reset()

View file

@ -2,8 +2,76 @@
Test the functions in the `logger` module Test the functions in the `logger` module
""" """
import datetime import datetime
from poezio.logger import LogMessage, parse_log_line, parse_log_lines, build_log_message from pathlib import Path
from poezio.common import get_utc_time, get_local_time from random import sample
from shutil import rmtree
from string import hexdigits
from poezio import logger
from poezio.logger import (
LogMessage, parse_log_line, parse_log_lines, build_log_message
)
from poezio.ui.types import Message
from poezio.common import get_utc_time
import pytest
class ConfigShim:
def __init__(self, value):
self.value = value
def get_by_tabname(self, name, *args, **kwargs):
return self.value
logger.config = ConfigShim(True)
@pytest.fixture
def log_dir():
name = 'tmplog-' + ''.join(sample(hexdigits, 16))
path = Path('/tmp', name)
try:
yield path
finally:
rmtree(path, ignore_errors=True)
def read_file(logger, name):
if '/' in name:
name = name.replace('/', '\\')
filename = logger.log_dir / f'{name}'
with open(filename) as fd:
return fd.read()
def test_log_roster(log_dir):
instance = logger.Logger()
instance.log_dir = log_dir
instance.log_roster_change('toto@example.com', 'test test')
content = read_file(instance, 'roster.log')
assert content[:3] == 'MI '
assert content[-32:] == ' 000 toto@example.com test test\n'
def test_log_message(log_dir):
instance = logger.Logger()
instance.log_dir = log_dir
msg = Message('content', 'toto')
instance.log_message('toto@example.com', msg)
content = read_file(instance, 'toto@example.com')
line = parse_log_lines(content.split('\n'), '')[0]
assert line['nickname'] == 'toto'
assert line['txt'] == 'content'
msg2 = Message('content\ncontent2', 'titi')
instance.log_message('toto@example.com', msg2)
content = read_file(instance, 'toto@example.com')
lines = parse_log_lines(content.split('\n'), '')
assert lines[0]['nickname'] == 'toto'
assert lines[0]['txt'] == 'content'
assert lines[1]['nickname'] == 'titi'
assert lines[1]['txt'] == 'content\ncontent2'
def test_parse_message(): def test_parse_message():
line = 'MR 20170909T09:09:09Z 000 <nick>  body' line = 'MR 20170909T09:09:09Z 000 <nick>  body'
@ -17,17 +85,27 @@ def test_parse_message():
def test_log_and_parse_messages(): def test_log_and_parse_messages():
msg1 = {'nick': 'toto', 'msg': 'coucou', 'date': datetime.datetime.now().replace(microsecond=0)} msg1 = {
'nick': 'toto',
'msg': 'coucou',
'date': datetime.datetime.now().replace(microsecond=0),
'prefix': 'MR',
}
msg1_utc = get_utc_time(msg1['date']) msg1_utc = get_utc_time(msg1['date'])
built_msg1 = build_log_message(**msg1) built_msg1 = build_log_message(**msg1)
assert built_msg1 == 'MR %s 000 <toto>  coucou\n' % (msg1_utc.strftime('%Y%m%dT%H:%M:%SZ')) assert built_msg1 == 'MR %s 000 <toto>  coucou\n' % (msg1_utc.strftime('%Y%m%dT%H:%M:%SZ'))
msg2 = {'nick': 'toto', 'msg': 'coucou\ncoucou', 'date': datetime.datetime.now().replace(microsecond=0)} msg2 = {
'nick': 'toto',
'msg': 'coucou\ncoucou',
'date': datetime.datetime.now().replace(microsecond=0),
'prefix': 'MR',
}
built_msg2 = build_log_message(**msg2) built_msg2 = build_log_message(**msg2)
msg2_utc = get_utc_time(msg2['date']) msg2_utc = get_utc_time(msg2['date'])
assert built_msg2 == 'MR %s 001 <toto>  coucou\n coucou\n' % (msg2_utc.strftime('%Y%m%dT%H:%M:%SZ')) assert built_msg2 == 'MR %s 001 <toto>  coucou\n coucou\n' % (msg2_utc.strftime('%Y%m%dT%H:%M:%SZ'))
assert parse_log_lines((built_msg1 + built_msg2).split('\n'), 'user@domain') == [ assert parse_log_lines((built_msg1 + built_msg2).split('\n'), 'user@domain') == [
{'time': msg1['date'], 'history': True, 'txt': '\x195,-1}coucou', 'nickname': 'toto'}, {'time': msg1['date'], 'history': True, 'txt': 'coucou', 'nickname': 'toto'},
{'time': msg2['date'], 'history': True, 'txt': '\x195,-1}coucou\ncoucou', 'nickname': 'toto'}, {'time': msg2['date'], 'history': True, 'txt': 'coucou\ncoucou', 'nickname': 'toto'},
] ]