Thursday, September 2, 2021

Mike Driscoll: Creating a File Search GUI with wxPython

Have you ever needed to search for a file on your computer? Most operating systems have a way to do this. Windows Explorer has a search function and there’s also a search built-in to the Start Menu now. Other operating systems like Mac and Linux are similar. There are also applications that you can download that are sometimes faster at searching your hard drive than the built-in ones are.

In this article, you will be creating a simple file search utility using wxPython.

You will want to support the following tasks for the file search tool:

  • Search by file type
  • Case sensitive searches
  • Search in sub-directories

You can download the source code from this article on GitHub.

Let’s get started!

Designing Your File Search Utility

It is always fun to try to recreate a tool that you use yourself. However in this case, you will just take the features mentioned above and create a straight-forward user interface. You can use a wx.SearchCtrl for searching for files and an ObjectListView for displaying the results. For this particular utility, a wx.CheckBox or two will work nicely for telling your application to search in sub-directories or if the search term is case-sensitive or not.

Here is a mockup of what the application will eventually look like:

File Search MockupFile Search Mockup

Now that you have a goal in mind, let’s go ahead and start coding!

Creating the File Search Utility

Your search utility will need two modules. The first module will be called main and it will hold your user interface and most of the application’s logic. The second module is named search_threads and it will contain the logic needed to search your file system using Python’s threading module. You will use pubsub to update the main module as results are found.

The main script

The main module has the bulk of the code for your application. If you go on and enhance this application, the search portion of the code could end up having the majority of the code since that is where a lot of the refinement of your code should probably go.

Regardless, here is the beginning of main:

# main.py

import os
import sys
import subprocess
import time
import wx

from ObjectListView import ObjectListView, ColumnDefn
from pubsub import pub
from search_threads import SearchFolderThread, SearchSubdirectoriesThread

This time around, you will be using a few more built-in Python modules, such as os, sys, subprocess and time. The other imports are pretty normal, with the last one being a couple of classes that you will be creating based around Python’s Thread class from the threading module.

For now though, let’s just focus on the main module.

Here’s the first class you need to create:

class SearchResult:

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

The SearchResult class is used for holding information about the results from your search. It is also used by the ObjectListView widget. Currently, you will use it to hold the full path to the search result as well as the file’s modified time. You could easily enhance this to also include file size, creation time, etc.

Now let’s create the MainPanel which houses most of UI code:

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')

The __init__() method gets everything set up. Here you create the main_sizer, an empty list of search_results and a listener or subscription using pubsub. You also call create_ui() to add the user interface widgets to the panel.

Let’s see what’s in create_ui() now:

def create_ui(self):
    # Create the 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)

There are quite a few widgets to add to this user interface. To start off, you add a row of widgets that consists of a label, a text control and a button. This series of widgets allows the user to choose which directory they want to search using the button. The text control will hold their choice.

Now let’s add another row of widgets:

# Create search filter widgets
row_sizer = wx.BoxSizer()
lbl = wx.StaticText(self, label='Limit search to filetype:')
row_sizer.Add(lbl, 0, wx.ALL|wx.CENTER, 5)

self.file_type = wx.TextCtrl(self)
row_sizer.Add(self.file_type, 0, wx.ALL, 5)

self.sub_directories = wx.CheckBox(self, label='Sub-directories')
row_sizer.Add(self.sub_directories, 0, wx.ALL | wx.CENTER, 5)

self.case_sensitive = wx.CheckBox(self, label='Case-sensitive')
row_sizer.Add(self.case_sensitive, 0, wx.ALL | wx.CENTER, 5)
self.main_sizer.Add(row_sizer)

This row of widgets contains another label, a text control and two instances of wx.Checkbox. These are the filter widgets which control what you are searching for. You can filter based on any of the following:

  • The file type
  • Search sub-directories (when checked) or just the chosen directory
  • The search term is case-sensitive

The latter two options are represented by using the wx.Checkbox widget.

Let’s add the search control next:

# 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)

The wx.SearchCtrl is the widget to use for searching. You could quite easily use a wx.TextCtrl instead though. Regardless, in this case you bind to the press of the Enter key and to the mouse click of the magnifying class within the control. If you do either of these actions, you will call search().

Now let’s add the last two widgets and you will be done with the code for create_ui():

# 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.main_sizer.Add(self.search_results_olv, 1, wx.ALL | wx.EXPAND, 5)
self.update_ui()

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 results of your search will appear in your ObjectListView widget. You also need to add a button that will attempt to show the result in the containing folder, kind of like how Mozilla Firefox has a right-click menu called “Open Containing Folder” for opening downloaded files.

The next method to create is on_choose_folder():

def on_choose_folder(self, event):
    with wx.DirDialog(self, "Choose a directory:",
                      style=wx.DD_DEFAULT_STYLE,
                      ) as dlg:
        if dlg.ShowModal() == wx.ID_OK:
            self.directory.SetValue(dlg.GetPath())

You need to allow the user to select a folder that you want to conduct a search in. You could let the user type in the path, but that is error-prone and you might need to add special error checking. Instead, you opt to use a wx.DirDialog, which prevents the user from entering a non-existent path. It is possible for the user to select the folder, then delete the folder before executing the search, but that would be an unlikely scenario.

Now you need a way to open a folder with Python:

def on_show_result(self, event):
    """
    Attempt to open the folder that the result was found in
    """
    result = self.search_results_olv.GetSelectedObject()
    if result:
        path = os.path.dirname(result.path)
        try:
            if sys.platform == 'darwin':
                subprocess.check_call(['open', '--', path])
            elif 'linux' in sys.platform:
                subprocess.check_call(['xdg-open', path])
            elif sys.platform == 'win32':
                subprocess.check_call(['explorer', path])
        except:
            if sys.platform == 'win32':
                # Ignore error on Windows as there seems to be
                # a weird return code on Windows
                return

            message = f'Unable to open file manager to {path}'
            with wx.MessageDialog(None, message=message,
                                  caption='Error',
                                  style= wx.ICON_ERROR) as dlg:
                dlg.ShowModal()

The on_show_result() method will check what platform the code is running under and then attempt to launch that platform’s file manager. Windows uses Explorer while Linux uses xdg-open for example.

During testing, it was noticed that on Windows, Explorer returns a non-zero result even when it opens Explorer successfully, so in that case you just ignore the error. But on other platforms, you can show a message to the user that you were unable to open the folder.

The next bit of code you need to write is the on_search() event handler:

def on_search(self, event):
    search_term = self.search_ctrl.GetValue()
    file_type = self.file_type.GetValue()
    file_type = file_type.lower()
    if '.' not in file_type:
        file_type = f'.{file_type}'

    if not self.sub_directories.GetValue():
        # Do not search sub-directories
        self.search_current_folder_only(search_term, file_type)
    else:
        self.search(search_term, file_type)

When you click the “Search” button, you want it to do something useful. That is where the code above comes into play. Here you get the search_term and the file_type. To prevent issues, you put the file type in lower case and you will do the same thing during the search.

Next you check to see if the sub_directories check box is checked or not. If sub_directories is unchecked, then you call search_current_folder_only(); otherwise you call search().

Let’s see what goes into search() first:

def search(self, search_term, file_type):
    """
    Search for the specified term in the directory and its
    sub-directories
    """
    folder = self.directory.GetValue()
    if folder:
        self.search_results = []
        SearchSubdirectoriesThread(folder, search_term, file_type,
                                   self.case_sensitive.GetValue())

Here you grab the folder that the user has selected. In the event that the user has not chosen a folder, the search button will not do anything. But if they have chosen something, then you call the SearchSubdirectoriesThread thread with the appropriate parameters. You will see what the code in that class is in a later section.

But first, you need to create the search_current_folder_only() method:

def search_current_folder_only(self, search_term, file_type):
    """
    Search for the specified term in the directory only. Do
    not search sub-directories
    """
    folder = self.directory.GetValue()
    if folder:
        self.search_results = []
        SearchFolderThread(folder, search_term, file_type,
                           self.case_sensitive.GetValue())

This code is pretty similar to the previous function. Its only difference is that it executes
SearchFolderThread instead of SearchSubdirectoriesThread.

The next function to create is update_search_results():

def update_search_results(self, result):
    """
    Called by pubsub from thread
    """
    if result:
        path, modified_time = result
        self.search_results.append(SearchResult(path, modified_time))
    self.update_ui()

When a search result is found, the thread will post that result back to the main application using a thread-safe method and pubsub. This method is what will get called assuming that the topic matches the subscription that you created in the __init__(). Once called, this method will append the result to search_results and then call update_ui().

Speaking of which, you can code that up now:

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

The update_ui() method defines the columns that are shown in your ObjectListView widget. It also calls SetObjects() which will update the contents of the widget and show your search results to the user.

To wrap up the main module, you will need to write the Search class:

class Search(wx.Frame):

    def __init__(self):
        super().__init__(None, title='Search Utility',
                         size=(600, 600))
        pub.subscribe(self.update_status, 'status')
        panel = MainPanel(self)
        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)

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

This class creates the MainPanel which holds most of the widgets that the user will see and interact with. It also sets the initial size of the application along with its title. There is also a status bar that will be used to communicate to the user when a search has finished and how long it took for said search to complete.

Here is what the application will look like:

A Search Utility

Now let’s move on and create the module that holds your search threads.

The search_threads Module

The search_threads module contains the two Thread classes that you will use for searching your file system. The thread classes are actually quite similar in their form and function.

Let’s get started:

# search_threads.py

import os
import time
import wx

from pubsub import pub
from threading import Thread

These are the modules that you will need to make this code work. You will be using the os module to check paths, traverse the file system and get statistics from files. You will use pubsub to communicate with your application when your search returns results.

Here is the first class:

class SearchFolderThread(Thread):

    def __init__(self, folder, search_term, file_type, case_sensitive):
        super().__init__()
        self.folder = folder
        self.search_term = search_term
        self.file_type = file_type
        self.case_sensitive = case_sensitive
        self.start()

This thread takes in the folder to search in, the search_term to look for, a file_type filter and whether or not the search term is case_sensitive. You take these in and assign them to instance variables of the same name. The point of this thread is only to search the contents of the folder that is passed-in, not its sub-directories.

You will also need to override the thread’s run() method:

def run(self):
    start = time.time()
    for entry in os.scandir(self.folder):
        if entry.is_file():
            if self.case_sensitive:
                path = entry.name
            else:
                path = entry.name.lower()

            if self.search_term in path:
                _, ext = os.path.splitext(entry.path)
                data = (entry.path, entry.stat().st_mtime)
                wx.CallAfter(pub.sendMessage, 'update', result=data)
    end = time.time()
    # Always update at the end even if there were no results
    wx.CallAfter(pub.sendMessage, 'update', result=[])
    wx.CallAfter(pub.sendMessage, 'status', search_time=end-start)

Here you collect the start time of the thread. Then you use os.scandir() to loop over the contents of the folder. If the path is a file, you will check to see if the search_term is in the path and has the right file_type. Should both of those return True, then you get the requisite data and send it to your application using wx.CallAfter(), which is a thread-safe method.

Finally you grab the end_time and use that to calculate the total run time of the search and then send that back to the application. The application will then update the status bar with the search time.

Now let’s check out the other class:

class SearchSubdirectoriesThread(Thread):

    def __init__(self, folder, search_term, file_type, case_sensitive):
        super().__init__()
        self.folder = folder
        self.search_term = search_term
        self.file_type = file_type
        self.case_sensitive = case_sensitive
        self.start()

The SearchSubdirectoriesThread thread is used for searching not only the passed-in folder but also its sub-directories. It accepts the same arguments as the previous class.

Here is what you will need to put in its run() method:

def run(self):
    start = time.time()
    for root, dirs, files in os.walk(self.folder):
        for f in files:
            full_path = os.path.join(root, f)
            if not self.case_sensitive:
                full_path = full_path.lower()

            if self.search_term in full_path and os.path.exists(full_path):
                _, ext = os.path.splitext(full_path)
                data = (full_path, os.stat(full_path).st_mtime)
                wx.CallAfter(pub.sendMessage, 'update', result=data)

    end = time.time()
    # Always update at the end even if there were no results
    wx.CallAfter(pub.sendMessage, 'update', result=[])
    wx.CallAfter(pub.sendMessage, 'status', search_time=end-start)

For this thread, you need to use os.walk() to search the passed in folder and its sub-directories. Besides that, the conditional statements are virtually the same as the previous class.

Wrapping Up

Creating search utilities is not particularly difficult, but it can be time-consuming. Figuring out the edge cases and how to account for them is usually what takes the longest when creating software. In this article, you learned how to create a utility to search for files on your computer.

Here are a few enhancements that you could add to this program:

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

Related Reading

Want to learn how to create more GUI applications with wxPython? Then check out these resources below:

The post Creating a File Search GUI with wxPython appeared first on Mouse Vs Python.



from Planet Python
via read more

No comments:

Post a Comment

TestDriven.io: Working with Static and Media Files in Django

This article looks at how to work with static and media files in a Django project, locally and in production. from Planet Python via read...