pythonpython-3.xgtkgtk3arch

Continously read from log with Python to display in GTK window


I'm trying to write a small application GUI (GTK) application with Python 3.9 and PyGObject that reads from a log file and displays the data in a GUI window.

In this particular case I'm trying to continuously read from the systemd Journal, which can be done e.g. with journalctl -f via the terminal.

For a CLI app I saw one could use something like this to pipe the output of the log file to the STOUT of the python script:

p = Popen(["journalctl", "--user-unit=appToMonitor"], stdout=PIPE)
with p.stdout:
    for line in iter(p.stdout.readline, b''):
         print(line, end=''),
p.wait()

OFC for a GUI app I need to somehow hook into the GTK main loop so the above solution does not work. I did some googling and from what I can see a solution is to use gobject.timeout_add() to regularly poll the log file for changes.

Is this the best way to get data from a system log file or is there another solution that can be used to avoid polling? If gobject.timeout_add() is the way to go, how can I ensure that I'll be only adding the log lines that have been added since the last read from the log file?


Solution

  • It's not necessary to use gobject.timeout_add(); you can just use the threading module, which specializes in threads, and is simpler than trying to use GTK.

    Using `threading` to call a function

    The nice thing about using threading is that, even though it's not part of the Gtk group of modules, it still doesn't block Gtk's main loop. Here is a simple example of a simple timeout thread using threading:

    import threading
    
    def function():
        print("Hello!")
    
    # Create a timer
    timer = threading.Timer(
        1, # Interval (in seconds; can be a float) after which to call the function
        function # Function to call after time interval
    )
    timer.start() # Start the timer
    

    This calls function() once after a period of 1 second, and then exits.

    However, in your case, you want to call the function multiple times, to repeatedly check the state of the logs. For this, you can recreate the timer inside function(), and run it again:

    import threading
    
    def function():
        global timer
        print("Hello!")
    
        # Recreate and start the timer
        timer = threading.Timer(1, function)
        timer.start()
    
    timer = threading.Timer(1, function)
    timer.start()
    

    Now to check if any new lines have been added to the log, the program needs to read the log and then compare the lines of the most recent reading to the lines of the previous reading.


    Reading the lines, and using `difflib` to compare them

    First of all, to compare the lines at all, they need to be stored in two lists: one containing the most recently read set of lines, the other containing the previous set of lines. Here is an example of code that reads the output of the log, stores the lines in a list, and then prints the list:

    from subprocess import PIPE, Popen
    
    # The list storing the lines of the log
    current_lines = []
    
    # Read the log
    p = Popen(["journalctl", "--user-unit=appToMonitor"], stdout=PIPE)
    with p.stdout:
        for line in iter(p.stdout.readline, b''):
            current_lines.append(line.decode("utf-8")) # Add the lines of the log to the list
    p.wait()
    
    print(current_lines)
    

    Note that line.decode("uft-8") must be used, because the output of p.stdout.readline is in bytes. Use whatever encoding your program uses; utf-8 is just a common encoding.

    You can then use the difflib module to compare the two lists. Here is an example program that compares two lists of lines, and prints whatever lines are in the second list and not in the first:

    import difflib
    
    # Two lists to compare
    last_lines = ["Here is the first line\n"]
    current_lines = ["Here is the first line\n", "and here is the second line"]
    
    # Iterate through all the lines, and check for new ones
    for line in difflib.ndiff(last_lines, current_lines):
    
        # Print the line only if it was not in last_lines
        if line[0] == "+": # difflib inserts a "+" for every addition
            print("Line added:", line.replace("+ ", "")) # Remove the "+" from the line and print it
    

    Great, but can how do we put all this into one program?

    The finale: putting all these concepts into one program that reads the output from the log every second, and adds any new lines to the text widget in the window. Here is a simple Gtk application that does just that:

    import difflib
    import gi
    import threading
    
    gi.require_version("Gtk", "3.0")
    from gi.repository import Gtk
    from subprocess import PIPE, Popen
    
    class App(Gtk.Window):
        """The application window."""
    
        def __init__(self):
            Gtk.Window.__init__(self)
            self.connect("delete-event", self.quit)
    
            # The list of lines read from the log file
            self.current_lines = []
    
            # The list of previously read lines to compare to the current one
            self.last_lines = []
    
            # The box to hold the widgets in the window
            self.box = Gtk.VBox()
            self.add(self.box)
    
            # The text widget to output the log file to
            self.text = Gtk.TextView()
            self.text.set_editable(False)
            self.box.pack_start(self.text, True, True, 0)
    
            # A button to demonstrate non-blocking
            self.button = Gtk.Button.new_with_label("Click")
            self.box.pack_end(self.button, True, True, 0)
    
            # Add a timer thread
            self.timer = threading.Timer(0.1, self.read_log)
            self.timer.start()
    
            self.show_all()
    
        def quit(self, *args):
            """Quit."""
            # Stop the timer, in case it is still waiting when the window is closed
            self.timer.cancel()
            Gtk.main_quit()
    
        def read_log(self):
            """Read the log."""
    
            # Read the log
            self.current_lines = []
            p = Popen(["journalctl", "--user-unit=appToMonitor"], stdout=PIPE)
            with p.stdout:
                for line in iter(p.stdout.readline, b''):
                    self.current_lines.append(line.decode("utf-8"))
            p.wait()
    
            # Compare the log with the previous reading
            for d in difflib.ndiff(self.last_lines, self.current_lines):
    
                # Check if this is a new line, and if so, add it to the TextView
                if d[0] == "+":
                    self.text.set_editable(True)
                    self.text.get_buffer().insert_at_cursor(d.replace("+ ", ""))
                    self.text.set_editable(False)
    
            self.last_lines = self.current_lines
    
            # Reset the timer
            self.timer = threading.Timer(1, self.read_log)
            self.timer.start()
    
    if __name__ == "__main__":
        app = App()
        Gtk.main()
    

    The button demonstrates that threading doesn't block execution while waiting; you can click it all you want, even while the program is reading the log!