summaryrefslogtreecommitdiff
path: root/poezio/logger.py
diff options
context:
space:
mode:
Diffstat (limited to 'poezio/logger.py')
-rw-r--r--poezio/logger.py312
1 files changed, 225 insertions, 87 deletions
diff --git a/poezio/logger.py b/poezio/logger.py
index 14882f00..29eaad32 100644
--- a/poezio/logger.py
+++ b/poezio/logger.py
@@ -3,7 +3,7 @@
# This file is part of Poezio.
#
# Poezio is free software: you can redistribute it and/or modify
-# it under the terms of the zlib license. See the COPYING file.
+# it under the terms of the GPL-3.0+ license. See the COPYING file.
"""
The logger module that handles logging of the poezio
conversations and roster changes
@@ -11,20 +11,21 @@ conversations and roster changes
import mmap
import re
-from typing import List, Dict, Optional, IO, Any
+from typing import List, Dict, Optional, IO, Any, Union, Generator
from datetime import datetime
+from pathlib import Path
from poezio import common
from poezio.config import config
from poezio.xhtml import clean_text
-from poezio.theming import dump_tuple, get_theme
+from poezio.ui.types import Message, BaseMessage, LoggableTrait
+from slixmpp import JID
+from poezio.types import TypedDict
import logging
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'
r'(\d{2}):(\d{2}):(\d{2})Z '
r'(\d+) <([^ ]+)>  (.*)$')
@@ -34,8 +35,13 @@ INFO_LOG_RE = re.compile(r'^MI (\d{4})(\d{2})(\d{2})T'
class LogItem:
- def __init__(self, year, month, day, hour, minute, second, nb_lines,
- message):
+ time: datetime
+ 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(
int(year), int(month), int(day), int(hour), int(minute),
int(second))
@@ -49,18 +55,37 @@ class LogInfo(LogItem):
class LogMessage(LogItem):
- def __init__(self, year, month, day, hour, minute, seconds, nb_lines, nick,
- message):
+ nick: str
+
+ 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,
nb_lines, message)
self.nick = nick
-def parse_log_line(msg: str, jid: str) -> Optional[LogItem]:
- match = re.match(MESSAGE_LOG_RE, msg)
+LogDict = TypedDict(
+ 'LogDict',
+ {
+ 'type': str, 'txt': str, 'time': datetime,
+ 'history': bool, 'nickname': str
+ },
+ total=False,
+)
+
+
+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 = MESSAGE_LOG_RE.match(msg)
if match:
return LogMessage(*match.groups())
- match = re.match(INFO_LOG_RE, msg)
+ match = INFO_LOG_RE.match(msg)
if match:
return LogInfo(*match.groups())
log.debug('Error while parsing %s’s logs: “%s”', jid, msg)
@@ -72,60 +97,120 @@ class Logger:
Appends things to files. Error/information/warning logs
and also log the conversations to logfiles
"""
+ _roster_logfile: Optional[IO[str]]
+ log_dir: Path
+ _fds: Dict[str, IO[str]]
+ _busy_fds: Dict[str, bool]
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)
- self._fds = {} # type: Dict[str, IO[Any]]
+ self._fds = {}
+ self._busy_fds = {}
+ self._buffered_fds = {}
def __del__(self):
+ """Close all fds on exit"""
for opened_file in self._fds.values():
if opened_file:
try:
opened_file.close()
- except: # Can't close? too bad
+ except Exception: # Can't close? too bad
pass
+ try:
+ self._roster_logfile.close()
+ except Exception:
+ pass
- def close(self, jid) -> None:
- jid = str(jid).replace('/', '\\')
- if jid in self._fds:
- self._fds[jid].close()
+ def get_file_path(self, jid: Union[str, JID]) -> Path:
+ """Return the log path for a specific jid"""
+ jidstr = str(jid).replace('/', '\\')
+ return self.log_dir / jidstr
+
+ def fd_busy(self, jid: Union[str, JID]) -> None:
+ """Signal to the logger that this logfile is busy elsewhere.
+ And that the messages should be queued to be logged later.
+
+ :param jid: file name
+ """
+ jidstr = str(jid).replace('/', '\\')
+ self._busy_fds[jidstr] = True
+ if jidstr not in self._buffered_fds:
+ self._buffered_fds[jidstr] = []
+
+ def fd_available(self, jid: Union[str, JID]) -> None:
+ """Signal to the logger that this logfile is no longer busy.
+ And write messages to the end.
+
+ :param jid: file name
+ """
+ jidstr = str(jid).replace('/', '\\')
+ if jidstr in self._busy_fds:
+ del self._busy_fds[jidstr]
+ if jidstr in self._buffered_fds:
+ msgs = ''.join(self._buffered_fds.pop(jidstr))
+ if jidstr in self._fds:
+ self._fds[jidstr].close()
+ del self._fds[jidstr]
+ self.log_raw(jid, msgs)
+
+ def close(self, jid: str) -> None:
+ """Close the log file for a JID."""
+ jidstr = str(jid).replace('/', '\\')
+ if jidstr in self._fds:
+ self._fds[jidstr].close()
log.debug('Log file for %s closed.', jid)
- del self._fds[jid]
- return None
+ del self._fds[jidstr]
def reload_all(self) -> None:
"""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:
- 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')
+ if not_closed:
+ log.error('Unable to close log files for: %s', not_closed)
for room in self._fds:
self._check_and_create_log_dir(room)
log.debug('Log handle for %s re-created', room)
- return None
- def _check_and_create_log_dir(self, room: str,
- open_fd: bool = True) -> Optional[IO[Any]]:
+ def _check_and_create_log_dir(self, jid: Union[str, JID],
+ open_fd: bool = True) -> Optional[IO[str]]:
"""
Check that the directory where we want to log the messages
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(jid)):
return None
+ # POSIX filesystems don't support / in filename, so we replace it with a backslash
+ jid = str(jid).replace('/', '\\')
try:
- log_dir.mkdir(parents=True, exist_ok=True)
- except OSError as e:
+ self.log_dir.mkdir(parents=True, exist_ok=True)
+ except OSError:
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)
return None
if not open_fd:
return None
- filename = log_dir / room
+ filename = self.get_file_path(jid)
try:
fd = filename.open('a', encoding='utf-8')
- self._fds[room] = fd
+ self._fds[jid] = fd
return fd
except IOError:
log.error(
@@ -134,32 +219,53 @@ class Logger:
def log_message(self,
jid: str,
- nick: str,
- msg: str,
- date: Optional[datetime] = None,
- typ: int = 1) -> bool:
+ msg: Union[BaseMessage, Message]) -> bool:
"""
- log the message in the appropriate jid's file
- type:
- 0 = Don’t log
- 1 = Message
- 2 = Status/whatever
+ Log the message in the appropriate file
+
+ :param jid: JID of the entity for which to log the message
+ :param msg: Message to log
+ :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(jid)):
+ return True
+ if not isinstance(msg, LoggableTrait):
return True
- logged_msg = build_log_message(nick, msg, date=date, typ=typ)
+ date = msg.time
+ txt = msg.txt
+ nick = ''
+ typ = 'MI'
+ if isinstance(msg, Message):
+ nick = msg.nickname or ''
+ if msg.me:
+ txt = f'/me {txt}'
+ typ = 'MR'
+ logged_msg = build_log_message(nick, txt, date=date, prefix=typ)
if not logged_msg:
return True
- jid = str(jid).replace('/', '\\')
- if jid in self._fds.keys():
- fd = self._fds[jid]
+ return self.log_raw(jid, logged_msg)
+
+ def log_raw(self, jid: Union[str, JID], logged_msg: str, force: bool = False) -> bool:
+ """Log a raw string.
+
+ :param jid: filename
+ :param logged_msg: string to log
+ :param force: Bypass the buffered fd check
+ :returns: True if no error was encountered
+ """
+ jidstr = str(jid).replace('/', '\\')
+ if jidstr in self._fds.keys():
+ fd = self._fds[jidstr]
else:
option_fd = self._check_and_create_log_dir(jid)
if option_fd is None:
return True
fd = option_fd
- filename = log_dir / jid
+ filename = self.get_file_path(jid)
try:
+ if not force and self._busy_fds.get(jidstr):
+ self._buffered_fds[jidstr].append(logged_msg)
+ return True
fd.write(logged_msg)
except OSError:
log.error(
@@ -181,11 +287,15 @@ class Logger:
def log_roster_change(self, jid: str, message: str) -> bool:
"""
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(jid)):
return True
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:
try:
self._roster_logfile = filename.open('a', encoding='utf-8')
@@ -206,7 +316,7 @@ class Logger:
for line in lines:
self._roster_logfile.write(' %s\n' % line)
self._roster_logfile.flush()
- except:
+ except Exception:
log.error(
'Unable to write in the log file (%s)',
filename,
@@ -218,21 +328,19 @@ class Logger:
def build_log_message(nick: str,
msg: str,
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
- message types:
- 0 = Don’t 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)
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')
- prefix = 'MR' if typ == 1 else 'MI'
lines = msg.split('\n')
first_line = lines.pop(0)
nb_lines = str(len(lines)).zfill(3)
@@ -245,28 +353,62 @@ def build_log_message(nick: str,
return logged_msg + ''.join(' %s\n' % line for line in lines)
-def _get_lines_from_fd(fd: IO[Any], nb: int = 10) -> List[str]:
+def last_message_in_archive(filepath: Path) -> Optional[LogDict]:
+ """Get the last message from the local archive.
+
+ :param filepath: the log file path
"""
- Get the last log lines from a fileno
+ last_msg = None
+ for msg in iterate_messages_reverse(filepath):
+ if msg['type'] == 'message':
+ last_msg = msg
+ break
+ return last_msg
+
+
+def iterate_messages_reverse(filepath: Path) -> Generator[LogDict, None, None]:
+ """Get the latest messages from the log file, one at a time.
+
+ :param fd: the file descriptor
"""
- with mmap.mmap(fd.fileno(), 0, prot=mmap.PROT_READ) as m:
- # start of messages begin with MI or MR, after a \n
- pos = m.rfind(b"\nM") + 1
- # number of message found so far
- count = 0
- while pos != 0 and count < nb - 1:
- count += 1
- pos = m.rfind(b"\nM", 0, pos) + 1
- lines = m[pos:].decode(errors='replace').splitlines()
- return lines
-
-
-def parse_log_lines(lines: List[str], jid: str) -> List[Dict[str, Any]]:
+ try:
+ with open(filepath, 'rb') as fd:
+ with mmap.mmap(fd.fileno(), 0, prot=mmap.PROT_READ) as m:
+ # start of messages begin with MI or MR, after a \n
+ pos = m.rfind(b"\nM") + 1
+ if pos != -1:
+ lines = parse_log_lines(
+ m[pos:-1].decode(errors='replace').splitlines()
+ )
+ elif m[0:1] == b'M':
+ # Handle the case of a single message present in the log
+ # file, hence no newline.
+ lines = parse_log_lines(
+ m[:].decode(errors='replace').splitlines()
+ )
+ if lines:
+ yield lines[0]
+ while pos > 0:
+ old_pos = pos
+ pos = m.rfind(b"\nM", 0, pos) + 1
+ lines = parse_log_lines(
+ m[pos:old_pos].decode(errors='replace').splitlines()
+ )
+ if lines:
+ yield lines[0]
+ except (OSError, ValueError):
+ pass
+
+
+def parse_log_lines(lines: List[str], jid: str = '') -> List[LogDict]:
"""
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 = []
- color = '\x19%s}' % dump_tuple(get_theme().COLOR_LOG_MSG)
# now convert that data into actual Message objects
idx = 0
@@ -281,16 +423,18 @@ def parse_log_lines(lines: List[str], jid: str) -> List[Dict[str, Any]]:
log.debug('wrong log format? %s', log_item)
continue
message_lines = []
- message = {
+ message = LogDict({
'history': True,
- 'time': common.get_local_time(log_item.time)
- }
+ 'time': common.get_local_time(log_item.time),
+ 'type': 'message',
+ })
size = log_item.nb_lines
if isinstance(log_item, LogInfo):
- message_lines.append(color + log_item.text)
+ message_lines.append(log_item.text)
+ message['type'] = 'info'
elif isinstance(log_item, LogMessage):
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):
message_lines.append(lines[idx][1:])
size -= 1
@@ -300,10 +444,4 @@ def parse_log_lines(lines: List[str], jid: str) -> List[Dict[str, Any]]:
return messages
-def create_logger() -> None:
- "Create the global logger object"
- global logger
- logger = Logger()
-
-
-logger = None # type: Logger
+logger = Logger()