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!
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.
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!
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:
It's time to start coding!
Your new text searching utility will be split up into three modules:
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 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:
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 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 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:
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:
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