Introduction
I am using Qt for Python, specific Qt Quick with QML and PySide6, to build a GUI application. The application is a log viewer, and I want a TreeView
QML type to display the hierarchy of logger names. The logger names, which are .
separated to build a hierarchy, get parsed from a log file to a list:
[ ["root", "child"], ["root"], ["asyncio", "clients", "camera"], ["root", "controller"], ["root", "controller", "telemetry"],]
which is then converted to a Python dictionary:
{"root": {"child": {},"controller": {"telemetry": {}}, },"asyncio": {"clients": {"camera": {}}},}
GUI prototype
Now, I want this dictionary to be presented as a tree view with checkboxes in the GUI such that it will toggle from which logger names/paths the log messages will display from. The tree view should look something like this:
The tree view is on the left.
QML and backend Python
To try and implement this in QML and Python, I have something that looks like this:
// Main.qmlimport QtQuickimport QtQuick.Windowimport QtQuick.Layoutsimport QtQuick.Controlsimport com.viewerWindow { width: 740 height: 540 visible: true title: "Python log viewer" RowLayout { TreeView { id: names Layout.preferredWidth: 100 Layout.fillWidth: true Layout.fillHeight: true model: LogViewer.log_names selectionModel: ItemSelectionModel {} delegate: Item { // Assigned to by TreeView: required property TreeView treeView required property bool isTreeNode required property bool expanded required property int hasChildren required property int depth required property int row required property int column required property bool current Text { text: treeView.index(row, column) } } } ScrollView { id: scrollView Layout.fillWidth: true TextArea { id: completeLog text: LogViewer.complete_log } } }}
Here is the backing Python code that implements a Qt singleton and the various models:
# main.py# These variables *must* be placed prior to the imports due to some particulars of how PySide6 works.# The import name is how QML code can import the singleton object defined in this file.# The import can be done like this: `import com.als.gui`QML_IMPORT_NAME = "com.viewer"QML_IMPORT_MAJOR_VERSION = 1# Core dependenciesfrom datetime import datetimefrom pathlib import Pathimport reimport sysfrom typing import NamedTuple, Any# Package dependenciesfrom PySide6.QtCore import QObject, Signal, Slot, Property, QTimer, Qt, QAbstractItemModel, QModelIndex # type: ignore[import-not-found]from PySide6.QtGui import QGuiApplication # type: ignore[import-not-found]from PySide6.QtQml import QQmlApplicationEngine, QmlElement, QmlSingleton # type: ignore[import-not-found]# How the logger name paths, which are normally dot-separated like "root.child",# are parsed into lists.logger_name_hierarchy = [ ["root", "child"], ["root"], ["asyncio", "clients", "camera"], ["root", "controller"], ["root", "controller", "telemetry"],]# Just an example of the tree as a dictionary that the above lists are rearranged intologger_name_dictionary = {"root": {"child": {},"controller": {"telemetry": {}}, },"asyncio": {"clients": {"camera": {}}},}class LogEntry(NamedTuple): Timestamp: datetime Name: list[str] Level: str Message: str File: str Line: int ThreadID: int ThreadName: str | None AsyncioTaskName: str | Nonelog_pattern = re.compile( r"(?P<timestamp>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}) :: " r"(?P<name>[^:].+) :: " r"(?P<level>[^:].+) :: " r"(?P<message>[^:].+) :: " r"(?P<file>[^:].+) :: " r"(?P<line>\d+) :: " r"(?P<thread_id>\d+) :: " r"(?P<thread_name>[^:].+) :: " r"(?P<asyncio_task_name>.+)")def parse_log_line(line: str) -> LogEntry: match = log_pattern.match(line) print(match) if match: return LogEntry( Timestamp=datetime.strptime( match.group("timestamp"), "%Y-%m-%d %H:%M:%S.%f" ), Name=match.group("name").split("."), Level=match.group("level"), Message=match.group("message"), File=match.group("file"), Line=int(match.group("line")), ThreadID=int(match.group("thread_id")), ThreadName=( match.group("thread_name") if match.group("thread_name") != "None" else None ), AsyncioTaskName=( match.group("asyncio_task_name") if match.group("asyncio_task_name") != "None" else None ), ) else: raise ValueError("Line does not match log pattern")def parse_log_file(log_file: str) -> list[LogEntry]: with open(log_file, "r") as file: return [ parse_log_line(line.strip()) for line in file.readlines() if line.strip() != "" ]def insert_into_tree(tree, path): current = tree for node in path: if node not in current: current[node] = {} current = current[node]def build_tree(paths): tree = {} for path in paths: insert_into_tree(tree, path) return tree# Ignore MyPy error: Class cannot subclass "QObject" (has type "Any")@QmlElement@QmlSingletonclass LogViewer(QObject): # type: ignore[misc] log_changed = Signal() status_changed = Signal() complete_log_changed = Signal() log_names_changed = Signal() def __init__(self) -> None: super().__init__() self.__log = self.complete_log self.complete_log_changed.emit() self.__log_names = self.log_names self.log_names_changed.emit() @Property(str, notify=complete_log_changed) def complete_log(self) -> str: lines = [str(line) for line in parse_log_file(r"example.log")] print(lines) return ("\n").join(lines) @Property(QAbstractItemModel, notify=log_names_changed) def log_names(self) -> QAbstractItemModel: tree = build_tree([line.Name for line in parse_log_file(r"example.log")]) # This needs to be a QAbstractItemModel that can be displayed in the QML # It is this where I am struggling what to do and how to properly subclass # and implement a QAbstractItemModel. return treeif __name__ == "__main__": application = QGuiApplication(sys.argv) engine = QQmlApplicationEngine() qml_file = Path(__file__).resolve().parent / "qml" / "Main.qml" engine.load(qml_file) if not engine.rootObjects(): sys.exit(-1) als_model: LogViewer = engine.singletonInstance("com.viewer", "LogViewer") print(als_model) sys.exit(application.exec())
Here is an example log file:
2024-07-01 19:16:03.326 :: root.child :: DEBUG :: This is a debug message :: __init__.py :: 22 :: 26480 :: MainThread :: None2024-07-01 19:16:03.326 :: root.child :: INFO :: This is an info message :: __init__.py :: 23 :: 26480 :: MainThread :: None2024-07-01 19:16:03.326 :: root.child :: WARNING :: This is a warning message :: __init__.py :: 24 :: 26480 :: MainThread :: None2024-07-01 19:16:03.326 :: root.child :: ERROR :: This is an error message :: __init__.py :: 25 :: 26480 :: MainThread :: None2024-07-01 19:16:03.326 :: root.child :: CRITICAL :: This is a critical message :: __init__.py :: 26 :: 26480 :: MainThread :: None2024-07-01 19:16:03.326 :: asyncio :: DEBUG :: This is a debug message :: __init__.py :: 28 :: 26480 :: MainThread :: None2024-07-01 19:16:03.326 :: asyncio :: INFO :: This is an info message :: __init__.py :: 29 :: 26480 :: MainThread :: None2024-07-01 19:16:03.326 :: asyncio :: WARNING :: This is a warning message :: __init__.py :: 30 :: 26480 :: MainThread :: None2024-07-01 19:16:03.326 :: asyncio :: ERROR :: This is an error message :: __init__.py :: 31 :: 26480 :: MainThread :: None2024-07-01 19:16:03.326 :: asyncio :: CRITICAL :: This is a critical message :: __init__.py :: 32 :: 26480 :: MainThread :: None2024-07-01 19:16:03.326 :: asyncio.clients :: DEBUG :: This is a debug message :: __init__.py :: 34 :: 26480 :: MainThread :: None2024-07-01 19:16:03.326 :: asyncio.clients :: INFO :: This is an info message :: __init__.py :: 35 :: 26480 :: MainThread :: None2024-07-01 19:16:03.326 :: asyncio.clients :: WARNING :: This is a warning message :: __init__.py :: 36 :: 26480 :: MainThread :: None2024-07-01 19:16:03.326 :: asyncio.clients :: ERROR :: This is an error message :: __init__.py :: 37 :: 26480 :: MainThread :: None2024-07-01 19:16:03.326 :: asyncio.clients :: CRITICAL :: This is a critical message :: __init__.py :: 38 :: 26480 :: MainThread :: None
Final questions
- How do I properly implement a
QAbstractItemModel
that "wraps" or converts the dictionary:
{"root": {"child": {},"controller": {"telemetry": {}}, },"asyncio": {"clients": {"camera": {}}},}
into something that TreeView
can display? It seems that this should be straightforward, but the Qt documentation is too terse on this for me to figure out, and I have been unable to find any examples. I was thinking that a dictionary could automatically be treated by Qt, as lists are for QAbstractListModel
, but that doesn't seem to be the case.
- Does my QML need adjusting once this is working, in particular for the selection model and delegate?
Thank you!