Creating a Text Search GUI with wxPython

You learned how to create a file search GUI with wxPython in an earlier tutorial. In this article, you will learn how to create a text search utility with wxPython.

If you'd like to learn more about creating GUI applications, you should check out my book Creating GUI Applications with wxPython on Leanpub, Gumroad, or Amazon.

You can download the source code from this article on GitHub. Note: This article depends on some of the code from Creating a File Search GUI with wxPython.

Now, let's get started!

The Text Search Utility

A text search utility is a tool that can search inside of other files for words or phrases, like the popular GNU grep tool. There are some tools that can also search Microsoft Word, PDF file contents and more. You will focus only on searching text files. These include files like XML, HTML, Python files and other code files in addition to regular text files.

There is a nice Python package that does the text search for us called grin. Since this book is using Python 3, you will want to use grin3 as that is the version of grin that is compatible with Python 3.

You can read all about this package here:

You will add a light-weight user interface on top of this package that allows you to use it to search text files.

Installing the Dependencies

You can install grin3 by using pip:

pip install grin3

Once installed, you will be able to run grin or grind from the command line on Mac or Linux. You may need to add it to your path if you are on Windows.

Warning: The previous version of grin3 is grin. If you install that into Python 3 and attempt to run it, you will see errors raised as grin is NOT Python 3 compatible. You will need to uninstall grin and install grin3 instead.

Now you can design your user interface!

Designing a Text Search Utility

You can take the code from the file search utility earlier in this chapter and modify the user interface for use with the text search. You don't care about the search term being case-sensitive right now, so you can remove that widget. You can also remove the sub-directories check box since grin will search sub-directories by default and that's what you want anyway.

You could filter by file-type still, but to keep things simple, let's remove that too. However you will need a way to display the files that were found along with the lines that contain the found text. To do that, you will need to add a multi-line text control in addition to the ObjectListView widget.

With all that in mind, here is the mockup:

Text Search Mockup

It's time to start coding!

Creating a Text Search Utility

Your new text searching utility will be split up into three modules:

  • The main module
  • The search_thread module
  • The preference module

The main module will contain the code for the main user interface. The search_thread module will contain the logic for searching for text using grin. And lastly, the preferences will be used for creating a dialog that you can use to save the location of the grin executable.

You can start by creating the main module now.

The main Module

The main module not only holds the user interface, but it will also check to make sure you have grin installed so that it will work. It will also launch the preferences dialog and show the user the search results, if any.

Here are the first few lines of code:

# main.py

import os
import sys
import subprocess
import time
import wx

from configparser import ConfigParser, NoSectionError
from ObjectListView import ObjectListView, ColumnDefn
from preferences import PreferencesDialog
from pubsub import pub
from search_thread import SearchThread

This main module has many of the same imports as the previous version of the main module. However in this one, you will be using Python's configparser module as well as creating a PreferencesDialog and a SearchThread. The rest of the imports should be pretty self-explanatory.

You will need to copy the SearchResult class over and modify it like this:

class SearchResult:

    def __init__(self, path, modified_time, data):
        self.path = path
        self.modified = time.strftime('%D %H:%M:%S',
                                      time.gmtime(modified_time))
        self.data = data

The class now accepts a new argument, data, which holds a string that contains references to all the places where the search term was found in the file. You will show that information to the user when the user selects a search result.

But first, you need to create the UI:

class MainPanel(wx.Panel):

    def __init__(self, parent):
        super().__init__(parent)
        self.search_results = []
        self.main_sizer = wx.BoxSizer(wx.VERTICAL)
        self.create_ui()
        self.SetSizer(self.main_sizer)
        pub.subscribe(self.update_search_results, 'update')

        module_path = os.path.dirname(os.path.abspath( __file__ ))
        self.config = os.path.join(module_path, 'config.ini')
        if not os.path.exists(self.config):
            message = 'Unable to find grin3 for text searches. ' \
                       'Install grin3 and open preferences to ' \
                       'configure it:  pip install grin3'
            self.show_error(message)

The MainPanel sets up an empty search_results list as before. It also creates the UI via a call to create_ui() and adds a pubsub subscription. But there is some new code added for getting the script's path and checking for a config file. If the config file does not exist, you show a message to the user letting them know that they need to install grin3 and configure the application using the Preferences menu.

Now let's see how the user interface code has changed:

def create_ui(self):
    # Create a widgets for the search path
    row_sizer = wx.BoxSizer()
    lbl = wx.StaticText(self, label='Location:')
    row_sizer.Add(lbl, 0, wx.ALL | wx.CENTER, 5)
    self.directory = wx.TextCtrl(self, style=wx.TE_READONLY)
    row_sizer.Add(self.directory, 1, wx.ALL | wx.EXPAND, 5)
    open_dir_btn = wx.Button(self, label='Choose Folder')
    open_dir_btn.Bind(wx.EVT_BUTTON, self.on_choose_folder)
    row_sizer.Add(open_dir_btn, 0, wx.ALL, 5)
    self.main_sizer.Add(row_sizer, 0, wx.EXPAND)

This code will create a horizontal row_sizer and add three widgets: a label, a text control that holds the folder to search in and a button for choosing said folder. This series of widgets are the same as the previous ones in the other code example.

In fact, so is the following search control code:

# Add search bar
self.search_ctrl = wx.SearchCtrl(
    self, style=wx.TE_PROCESS_ENTER, size=(-1, 25))
self.search_ctrl.Bind(wx.EVT_SEARCHCTRL_SEARCH_BTN, self.on_search)
self.search_ctrl.Bind(wx.EVT_TEXT_ENTER, self.on_search)
self.main_sizer.Add(self.search_ctrl, 0, wx.ALL | wx.EXPAND, 5)

Once again, you create an instance of wx.SearchCtrl and bind it to the same events and the same event handler. The event handler's code will be different, but you will see how that changes soon.

Let's finish out the widget code first:

# Search results widget
self.search_results_olv = ObjectListView(
    self, style=wx.LC_REPORT | wx.SUNKEN_BORDER)
self.search_results_olv.SetEmptyListMsg("No Results Found")
self.search_results_olv.Bind(wx.EVT_LIST_ITEM_SELECTED,
                             self.on_selection)
self.main_sizer.Add(self.search_results_olv, 1, wx.ALL | wx.EXPAND, 5)
self.update_ui()

self.results_txt = wx.TextCtrl(
    self, style=wx.TE_MULTILINE | wx.TE_READONLY)
self.main_sizer.Add(self.results_txt, 1, wx.ALL | wx.EXPAND, 5)

show_result_btn = wx.Button(self, label='Open Containing Folder')
show_result_btn.Bind(wx.EVT_BUTTON, self.on_show_result)
self.main_sizer.Add(show_result_btn, 0, wx.ALL | wx.CENTER, 5)

The on_selection event handler fires when the user selects a search result in the ObjectListView widget. You grab that selection and then set the value of the text control to the data attribute. The data attribute is a list of strings, so you need to use the string's join() method to join all those lines together using a newline character: \n. You want each line to be on its own line to make the results easier to read.

You can copy the on_show_result() method from the file search utility to this one as there are no changes needed for that method.

The next bit of new code to write is the on_search() method:

def on_search(self, event):
    search_term = self.search_ctrl.GetValue()
    self.search(search_term)

The on_search() method is quite a bit simpler this time in that you only need to get the search_term. You don't have any filters in this version of the application, which certainly reduces the code clutter. Once you have your term to search for, you call search().

Speaking of which, that is the next method to create:

def search(self, search_term):
    """
    Search for the specified term in the directory and its
    sub-directories
    """
    folder = self.directory.GetValue()
    config = ConfigParser()
    config.read(self.config)
    try:
        grin = config.get("Settings", "grin")
    except NoSectionError:
        self.show_error('Settings or grin section not found')
        return

    if not os.path.exists(grin):
        self.show_error(f'Grin location does not exist {grin}')
        return
    if folder:
        self.search_results = []
        SearchThread(folder, search_term)

The search() code will get the folder path and create a config object. It will then attempt to open the config file. If the config file does not exist or it cannot read the "Settings" section, you will show an error message. If the "Settings" section exists, but the path to the grin executable does not, you will show a different error message. But if you make it past these two hurdles and the folder itself is set, then you'll start the SearchThread. That code is saved in another module, so you'll have to wait to learn about that.

For now, let's see what goes in the show_error() method:

def show_error(self, message):
    with wx.MessageDialog(None, message=message,
                          caption='Error',
                          style= wx.ICON_ERROR) as dlg:
        dlg.ShowModal()

This method will create a wx.MessageDialog and show an error to the user with the message that was passed to it. The function is quite handy for showing errors. You can update it a bit if you'd like to show other types of messages as well though.

When a search completes, it will send a pubsub message out that will cause the following code to execute:

def update_search_results(self, results):
    """
    Called by pubsub from thread
    """
    for key in results:
        if os.path.exists(key):
            stat = os.stat(key)
            modified_time = stat.st_mtime
            result = SearchResult(key, modified_time, results[key])
            self.search_results.append(result)

    if results:
        self.update_ui()
    else:
        search_term = self.search_ctrl.GetValue()
        self.search_results_olv.ClearAll()
        msg = f'No Results Found for: "{search_term}"'
        self.search_results_olv.SetEmptyListMsg(msg)

This method takes in a dict of search results. It then loops over the keys in the dict and verifies that the path exists. If it does, then you use os.stat() to get information about the file and create a SearchResult object, which you then append() to your search_results.

When a search returns no results, you will want to clear out the search results widget and notify the user that their search didn't find anything.

The update_ui() code is pretty much exactly the same as the previous code:

def update_ui(self):
    self.search_results_olv.SetColumns([
        ColumnDefn("File Path", "left", 800, "path"),
        ColumnDefn("Modified Time", "left", 150, "modified")
    ])
    self.search_results_olv.SetObjects(self.search_results)

The only difference here is that the columns are a bit wider than they are in the file search utility. This is because a lot of the results that were found during testing tended to be rather long strings.

The code for the wx.Frame has also changed as you now have a menu to add:

class Search(wx.Frame):

    def __init__(self):
        super().__init__(None, title='Text Search Utility',
                         size=(1200, 800))
        pub.subscribe(self.update_status, 'status')
        panel = MainPanel(self)
        self.create_menu()
        self.statusbar = self.CreateStatusBar(1)
        self.Show()

    def update_status(self, search_time):
        msg = f'Search finished in {search_time:5.4} seconds'
        self.SetStatusText(msg)

Here you create the Search frame and set the size a bit wider than you did for the other utility. You also create the panel, create a subscriber and create a menu. The update_status() method is the same as last time.

The truly new bit was the call to create_menu() which is what's also next:

def create_menu(self):
    menu_bar = wx.MenuBar()

    # Create file menu
    file_menu = wx.Menu()

    preferences = file_menu.Append(
        wx.ID_ANY, "Preferences",
        "Open Preferences Dialog")
    self.Bind(wx.EVT_MENU, self.on_preferences,
              preferences)

    exit_menu_item = file_menu.Append(
        wx.ID_ANY, "Exit",
        "Exit the application")
    menu_bar.Append(file_menu, '&File')
    self.Bind(wx.EVT_MENU, self.on_exit,
              exit_menu_item)

    self.SetMenuBar(menu_bar)

In this code you create the MenuBar and add a file_menu. Within that menu, you add two menu items; one for preferences and one for exiting the application.

You can create the exit code first:

def on_exit(self, event):
    self.Close()

This code will execute if the user goes into the File menu and chooses "Exit". When they do that, your application will Close(). Since the frame is the top level window, when it closes, it will also destroy itself.

The final piece of code in this class is for creating the preferences dialog:

def on_preferences(self, event):
    with PreferencesDialog() as dlg:
        dlg.ShowModal()

Here you instantiate the PreferencesDialog and show it to the user. When the user closes the dialog, it will be automatically destroyed.

You will need to add the following code to the end of the file for your code to run:

if __name__ == '__main__':
    app = wx.App(False)
    frame = Search()
    app.MainLoop()

When you are done coding the rest of this application, it will look like this:

Text Search Utility

 

Note that regular expressions are allowed by grin when you do a search, so you can enter them in your GUI as well.

The next step is to create the threading code!

The search_thread Module

The search_thread module contains your logic for searching for text within files using the grin3 executable. You only need one subclass of Thread in this module as you will always search subdirectories.

The first step is to create the imports:

# search_thread.py

import os
import subprocess
import time
import wx

from configparser import ConfigParser
from pubsub import pub
from threading import Thread

For the search_thread module, you will need access to the os, subprocess and time modules. The new one being the subprocess module because you will be launching an external application. The other new addition here is the ConfigParser, which you use to get the executable's path from the config file.

Let's continue and create the SearchThread itself:

class SearchThread(Thread):

    def __init__(self, folder, search_term):
        super().__init__()
        self.folder = folder
        self.search_term = search_term
        module_path = os.path.dirname(os.path.abspath( __file__ ))
        self.config = os.path.join(module_path, 'config.ini')
        self.start()

The __init__() method takes in the target folder and the search_term to look for. It also recreates the module_path to derive the location of the config file.

The last step is to start() the thread. When that method is called, it rather incongruously calls the run() method.

Let's override that next:

def run(self):
    start = time.time()
    config = ConfigParser()
    config.read(self.config)
    grin = config.get("Settings", "grin")
    cmd = [grin, self.search_term, self.folder]
    output = subprocess.check_output(cmd, encoding='UTF-8')
    current_key = ''
    results = {}
    for line in output.split('\n'):
        if self.folder in line:
            # Remove the colon off the end of the line
            current_key = line[:-1]
            results[current_key] = []
        elif not current_key:
            # key not set, so skip it
            continue
        else:
            results[current_key].append(line)
    end = time.time()
    wx.CallAfter(pub.sendMessage,
                 'update',
                 results=results)
    wx.CallAfter(pub.sendMessage, 'status', search_time=end-start)

Here you add a start time and get the config which should be created at this point. Next you create a list of commands. The grin utility takes the search term and the directory to search as its main arguments. There are actually other arguments you could add to make the search more targeted, but that would require additional UI elements and your objective is to keep this application nice and simple.

The next step is to call subprocess.check_output() which takes the list of commands. You also set the encoding to UTF-8. This tells the subprocess module to return a string rather than byte-strings and it also verifies that the return value is zero.

The results that are returned now need to be parsed. You can loop over each line by splitting on the newline character. Each file path should be unique, so those will become the keys to your results dictionary. Note that you will need to remove the last character from the line as the key has a colon on the end. This makes the path invalid, so removing that is a good idea. Then for each line of data following the path, you append it to the value of that particular key in the dictionary.

Once done, you send out two messages via pubsub to update the UI and the status bar.

Now it's time to create the last module!

The preferences Module

The preferences module contains the code you will need for creating the PreferencesDialog which will allow you to configure where the grin executable is on your machine.

Let's start with the imports:

# preferences.py

import os
import wx

from configparser import ConfigParser

Fortunately, the import section of the module is short. You only need the os, wx and configparser modules to make this work.

Now that you have that part figured out, you can create the dialog itself by going into the File -> Preferences menu:

class PreferencesDialog(wx.Dialog):

    def __init__(self):
        super().__init__(None, title='Preferences')
        module_path = os.path.dirname(os.path.abspath( __file__ ))
        self.config = os.path.join(module_path, 'config.ini')
        if not os.path.exists(self.config):
            self.create_config()

        config = ConfigParser()
        config.read(self.config)
        self.grin = config.get("Settings", "grin")

        self.main_sizer = wx.BoxSizer(wx.VERTICAL)
        self.create_ui()
        self.SetSizer(self.main_sizer)

Here you create the __init__() method and get the module_path so that you can find the config. Then you verify that the config exists. If it doesn't, then you create the config file, but don't set the executable location.

You do attempt to get its location via config.get(), but if it is blank in the file, then you will end up with an empty string.

The last three lines set up a sizer and call create_ui().

You should write that last method next:

def create_ui(self):
    row_sizer = wx.BoxSizer()
    lbl = wx.StaticText(self, label='Grin3 Location:')
    row_sizer.Add(lbl, 0, wx.ALL | wx.CENTER, 5)
    self.grin_location = wx.TextCtrl(self, value=self.grin)
    row_sizer.Add(self.grin_location, 1, wx.ALL | wx.EXPAND, 5)
    browse_button = wx.Button(self, label='Browse')
    browse_button.Bind(wx.EVT_BUTTON, self.on_browse)
    row_sizer.Add(browse_button, 0, wx.ALL, 5)
    self.main_sizer.Add(row_sizer, 0, wx.EXPAND)

    save_btn = wx.Button(self, label='Save')
    save_btn.Bind(wx.EVT_BUTTON, self.save)
    self.main_sizer.Add(save_btn, 0, wx.ALL | wx.CENTER, 5)

In this code, you create a row of widgets. A label, a text control that holds the executable's path and a button for browsing to that path. You add all of these to the sizer which is then nested inside of the main_sizer. Then you add a "Save" button at the bottom of the dialog.

Here is the code for creating a config from scratch:

def create_config(self):
    config = ConfigParser()
    config.add_section("Settings")
    config.set("Settings", 'grin', '')

    with open(self.config, 'w') as config_file:
        config.write(config_file)

When the config does not exist, this code will get called. It instantiates a ConfigParser object and then adds the appropriate sections and settings to it. Then it writes it out to disk in the appropriate location.

The save() method is probably the next most important piece of code to write:

def save(self, event):
    grin_location = self.grin_location.GetValue()
    if not grin_location:
        self.show_error('Grin location not set!')
        return
    if not os.path.exists(grin_location):
        self.show_error(f'Grin location does not exist {grin_location}')
        return

    config = ConfigParser()
    config.read(self.config)
    config.set("Settings", "grin", grin_location)
    with open(self.config, 'w') as config_file:
        config.write(config_file)
    self.Close()

Here you get the location of the grin application from the text control and show an error if it is not set. You also show an error if the location does not exist. But if it is set and it does exist, then you open the config file back up and save that path to the config file for use by the main application. Once the save is finished, you Close() the dialog.

This last regular method is for showing errors:

def show_error(self, message):
    with wx.MessageDialog(None, message=message,
                          caption='Error',
                          style= wx.ICON_ERROR) as dlg:
        dlg.ShowModal()

This code is actually exactly the same as the show_error() method that you have in the main module. Whenever you see things like this in your code, you know that you should refactor it. This method should probably go into its own module that is then imported into the main and preferences modules. You can figure out how to do that on your own though.

Finally, you need to create the only event handler for this class:

def on_browse(self, event):
    """
    Browse for the grin file
    """
    wildcard = "All files (*.*)|*.*"
    with wx.FileDialog(None, "Choose a file",
                       wildcard=wildcard,
                       style=wx.ID_OPEN) as dialog:
        if dialog.ShowModal() == wx.ID_OK:
            self.grin_location.SetValue(dialog.GetPath())

This event handler is called when the user presses the "Browse" button to go find the grin executable. When they find the file, they can pick it and the text control will be set to its location.

Now that you have the dialog all coded up, here is what it looks like:

Preferences Dialog for Text Search

Wrapping Up

You now know how to create a text search utility using Python and the wxPython GUI toolkit.

Here are a few enhancements that you could add:

  • Add the ability to stop the search
  • Prevent multiple searches from occurring at the same time
  • Add other filters

you could also enhance it by adding support for more of grin's command-line options. Check out grin's documentation for more information on that topic.

Copyright © 2024 Mouse Vs Python | Powered by Pythonlibrary