In this walkthrough, our main goal is to demonstrate how to build a small software application with a reasonably complex GUI in Python with the help of PyQt5 and the QT Designer. The software is supposed to work as a stand-alone application but also as a script tool inside ArcGIS Pro. Furthermore, while part of its functionality will require arcpy, we also want it to provide some functionality if arcpy is not available. Therefore, this walkthrough will also show how one can check whether a Python script is run inside ArcGIS or not, and whether arcpy is available or not. Finally, since the software will query some geographic web services that return JSON code, the walkthrough will be a continuation of what we started in Section 2.3 [1] where you learned about Python packages that support dealing with URLs and web requests as well as working with JSON code.
Note: Unfortunately there currently is an issue with the newest versions of ArcGIS Pro (version 2.8 and above) that will make the application from this walkthrough crash when it is run as a script tool inside ArcGIS. We have not yet been able to find a workaround for this issue that does not require re-designing the GUI of the app. The issue seems to be related to specific QT5 widgets (menu bar, status bar, etc.) used in the program. You will still be able to run the software as a stand-alone application though if you have one of these versions.
The idea of the little software tool we are going to build is that sometimes you would like to be able to quickly create a feature class with points for certain places based on their names. Let’s say you want to produce a shapefile showing a few places in the city you are living in. What you could do is open a basemap in ArcGIS or some other GIS software and then digitize point features for these particular locations. Or you could look up the coordinates of the places in some external application such as Google Maps and then create new point features based on these coordinates manually or by exporting the coordinates in a format that can be imported by your GIS. Both these options are relatively time consuming and require several steps. We therefore want to exploit the fact that there exist many web services that allow for querying for geographic places based on name, type, and other properties, and getting back the coordinates. The two web services we are going to work with are the Nominatim query interface to OpenStreetMaps (OSM) and the place query interface of the online geographic gazetteer GeoNames. We will discuss these services in more detail below. Our tool should allow us to enter a query term such as a place name, pick a query service and set some additional query options, and then run the query to get a list of candidate places with coordinates. We can then look at the result list and pick one or more candidates from the list and either
Figure 2.20 and the video below show the GUI of the software and further illustrate how the tool works. The use case in the shown example is that we use the tool to populate a layer open in ArcGIS Pro with some of the main sightseeing locations in Paris including the Eiffel Tower, the Louvre, etc.
Please watch the following video, Location from Web Service Tool Overview (5:25min):
The GUI is organized into four main parts:
We can run as many queries as we wish with the tool and collect the results we want to keep in one of the possible output options. While we will only present a basic version of the tool in this walkthrough supporting two different query interfaces and direct input, the tool could easily be extended to provide access to other web portals so that it can be used for running queries to, for instance, get locations of all Starbucks located in a particular city or create locations from a list of addresses.
Before we continue with exploring the GUI and code of the application, let us talk about the two web services we are going to use:
Nominatim – Surely, you know about OpenStreetMaps (OSM) [3] and how it collects geographic data from volunteers all over the world to create a detailed map of the world. OSM data is freely available and can be directly exported from the OSM web site. In addition, there exist quite a few web services built around the OSM data, some of them created with the purpose of allowing for querying the data to only obtain information about particular features in the data. One such example is the Nominatim web service provided by OSM themselves. The website for Nominatim Open Street Maps [4] provides an easy to use interface to the Nominatim query engine. You can type in a search term at the top and then will get to see a list of results displayed on the left side of the page and an OSM based map on the right that shows the currently selected entity from the result list. Give it a try, for instance by running a query for “Eiffel tower, France”.
Note: With some recent changes, it seems Nominatim has become much more restrictive and will often only return a single result rather than multiple options. If you leave out the 'France' in the query, the only result returned will actually not be the Eiffel Tower in Paris. However, you will still get multiple results if you, for instance, enter 'Washington' as the query string. Due to these changes, the results you will get when using Nominatim in the Locations from Web Services Tool will partially deviate from what is shown in the figures and videos in this section (for instance, the list of options shown in Figure 2.25 when only querying for 'Eiffel tower' without country name.
Nominatim provides an HTTP based web API that can be used for running queries from your own code and getting the results back, for instance as JSON or XML data. The web API is explained on this wiki page here [5]. Query terms and additional parameters are encoded in a URL that has the general format:
https://nominatim.openstreetmap.org/search
where parameters are specified as <parameter name>=<parameter value> pairs and multiple parameters have to be separated by an & symbol. The parameter name for the query string is simply 'q' (so q=...). To run the query, the client sends an HTTP GET request with this URL to the Nominatim server who processes the query and parameters, derives the result, and sends back the results to the client. The format parameter controls how the result is presented and encoded. Without specifying that parameter, you will get the kind of HTML page that you already saw above with the result list and map. When using format=json as we will do in the following, we get the result as a list of entities encoded as JSON. Here is an example query URL, querying for “Eiffel tower, France” again, that you can test out simply by clicking on the link to open the URL in your browser:
https://nominatim.openstreetmap.org/search?q=Eiffel%20tower%2c%20France&format=json [6]
Have a look at the result shown in your browser. As we explained at the beginning of the lesson, JSON uses [...] to denote lists of entities
1 | [ <entity1, entity2>, ... ] |
where each entity is described by its properties like in a Python dictionary:
1 | {<property1>: <value>, <property2>: <value>, ...} |
Due to the changes, we mentioned above, the result will be a list with just a single entity, looking like this ...
[{ "place_id":115316817, "licence":"Data © OpenStreetMap contributors, ODbL 1.0. http://osm.org/copyright", "osm_type":"way", "osm_id":5013364, "lat":"48.8582599", "lon":"2.2945006358633115", "class":"man_made", "type":"tower", "place_rank":30, "importance":0.5868325701744196, "addresstype":"man_made", "name":"Eiffel Tower", "display_name":"Eiffel Tower, 5, Avenue Anatole France, Quartier du Gros-Caillou, 7th Arrondissement, Paris, Ile-de-France, Metropolitan France, 75007, France", "boundingbox":["48.8574753","48.8590453","2.2933119","2.2956897"] }]
We can see that the most important properties for us will be the ‘display_name’ property and the ‘lat’ and ‘lon’ properties (appearing in bold above) in order to create point features and add them to an existing data set with our tool. Feel free to compare this result to what you get when querying for 'Washington' where you will get a list of multiple results.
The following query uses a few more parameters to query for places called London in Canada (countrycodes=CA) and asking for only a single entity be returned (limit=1).
https://nominatim.openstreetmap.org/search?q=London&format=json&countrycodes=CA&limit=1 [7]
If you look at the result, you will see that it lists London in Ontario as the only result. Without using the ‘countrycodes’ parameter the result would have been London in the UK because Nominatim uses a ranking scheme to order entities by likelihood/prominence. Without the 'limit' parameter, we would get a list of multiple options in the JSON result. ‘format’, ‘countrycodes’ and ‘limit’ will be the only parameters we will be using in our tool but please have a look at the other parameters and examples given on the Nominatim wiki page to get an idea of what other kinds of queries could be implemented.
GeoNames – You have already seen the Nominatim examples, so we can keep the section about GeoNames a bit shorter, since the URLs for running queries are somewhat similar. GeoNames is essentially an online geographic gazetteer, so a directory of geographic place names with associated information including coordinates. The main page [8] can be used to type in queries directly but we will be using their REST web API that is documented here [9]. Instead of a parameter for specifying the output format, the API uses a special URL for running queries that are replied to using JSON, as in the following example:
http://api.geonames.org/searchJSON?name=Springfield&maxRows=10&username=demo [10]
Please note that a parameter (name=) is used to specify the query term. In addition, GeoNames requires a user name to be provided with the ‘username’ parameter. In case you tried out the link above, you probably got the reply that the daily request limit for user ‘demo’ has been reached. So you will have to create your own account at http://www.geonames.org/login and then use that user name instead of ‘demo’ in the query. The JSON sent back by GeoNames as the result will start like this:
{ "totalResultsCount": 3308, "geonames": [ { "adminCode1": "IL", "lng": "-89.64371", "geonameId": 4250542, "toponymName": "Springfield", "countryId": "6252001", "fcl": "P", "population": 116565, "countryCode": "US", "name": "Springfield", "fclName": "city, village,...", "adminCodes1": { "ISO3166_2": "IL" }, "countryName": "United States", "fcodeName": "seat of a first-order administrative division", "adminName1": "Illinois", "lat": "39.80172", "fcode": "PPLA" }, { "adminCode1": "MO", "lng": "-93.29824", "geonameId": 4409896, "toponymName": "Springfield", "countryId": "6252001", "fcl": "P", "population": 166810, "countryCode": "US", "name": "Springfield", "fclName": "city, village,...", "adminCodes1": { "ISO3166_2": "MO" }, "countryName": "United States", "fcodeName": "seat of a second-order administrative division", "adminName1": "Missouri", "lat": "37.21533", "fcode": "PPLA2" } ] }
Here the list of entities is stored under the property called ‘geonames’. Each entity in the list has the properties ‘toponymName’ with the entity name, ‘lng’ with the longitude coordinate, and ‘lat’ with the latitude coordinate. Query parameters we will be using in addition to ‘name’ and ‘username’ are ‘maxRows’ to determine the number of results sent back, ‘country’ to restrict the search to a single country, and ‘featureClass’ to look only for features of a particular type (codes A,H,L,P,R,S,T,U,V of the GeoNames feature class codes defined here [11]).
The GUI of our software application is not too complex but still uses many of the most common GUI elements such as a toolbar with a tool button, normal push buttons, group boxes, tabs, labels, text input fields, checkboxes, combo boxes, list views, and a status bar. You already got to know most of these in Section 2.6 [12] but there are also some new ones. The GUI consists of two parts, the GUI for the main window and the GUI for the dialog box that is shown when the user clicks on the “Create new shapefile…” button. Therefore, we will be producing two different .ui files with QT Designer called gui_main.ui and gui_newshapefile.ui. The GUI of the main window uses a vertical layout to organize the different elements into four different rows as shown in the figure below.
Each of the rows is formed by a QGroupBox widget and then other widgets are arranged hierarchically within these group boxes using a combination of grid and horizontal layouts. The tool doesn’t have a menu bar but a toolbar at the top with a button to exit the program and a status bar at the bottom. When creating the GUI in QT Designer, it will be important to name the widgets we need to refer to from our main code as indicated by the orange labels in the two figures below. As we already pointed out, the object names given to the widgets in QT Designer will be the names of the variables used for storing the widgets when the .ui file is compiled into a .py file.
The series of videos linked below shows the process of creating the GUI in QT Designer. A zip file with the resulting .ui files is available for download here [13]. We recommend that you work along with the video, pausing it as needed, to create the GUI yourself to get some more practice with QT Designer. The downloadable .ui files are mainly intended as a fallback position in case you experience any difficulties while creating the GUI or later on when compiling the .ui files and using the produced Python code. If you cannot replicate what is shown in the video, please ask for help on the discussion forums.
Create the GUI along with this series of videos [~45 min of video materials] [14]
At this point, you either have saved your own two .ui files or, if you ran into any issues, will continue with these files downloaded from the link posted above. We now need to compile the files into Python code with pyuic5. We do this by running the ArcGIS Pro python.exe from the Windows command line with the pyuic module. The python.exe file is located in the folder “C:\Users \<username>\AppData\Local\ESRI\conda\envs\arcgispro-py3-clone\" (unless the ArcGIS Pro Python environment you are using is located in a different folder). So open a command shell, navigate to the folder containing the .ui files and then run the following two commands (again picking the correct version depending on where your default Python environment is installed and replacing <username> as needed):
"C:\Users\<username>\AppData\Local\ESRI\conda\envs\arcgispro-py3-clone\python.exe" -m PyQt5.uic.pyuic gui_main.ui -o gui_main.py
"C:\Program Files\ArcGIS\Pro\bin\Python\envs\arcgispro-py3\python.exe" -m PyQt5.uic.pyuic gui_main.ui -o gui_main.py
and
"C:\Users\<username>\AppData\Local\ESRI\conda\envs\arcgispro-py3-clone\python.exe" -m PyQt5.uic.pyuic gui_newshapefile.ui -o gui_newshapefile.py
"C:\Program Files\ArcGIS\Pro\bin\Python\envs\arcgispro-py3\python.exe" -m PyQt5.uic.pyuic gui_newshapefile.ui -o gui_newshapefile.py
Note: If you get a file not found error such as after running the command, remove the " - " symbol from the m and o and type them again. These symbols may come across as a different encoding and cause the command to fail.
The parameters given to pyuic5 are the name of the input .ui file and then –o followed by the name of the output file. You should now have the two files gui_main.py and gui_newshapefile.py in the project folder. Let us have a quick look at the produced code. Open the produced file gui_main.py in your preferred Python IDE and see whether you recognize and understand how the different elements are created and how their properties are set. Without going into the details, the code defines a class Ui_MainWindow with a method setupUi(…). The parameter MainWindow is for passing a QMainWindow widget to the method. The rest of the code of the method then either...
1 2 3 4 5 6 7 8 9 | class Ui_MainWindow( object ): def setupUi( self , MainWindow): … MainWindow.resize( 605 , 685 ) # changes property of main window widget self .centralwidget = QtWidgets.QWidget(MainWindow) # creates child widgets and stores them in attributes of the Ui_MainWindow object … MainWindow.setCentralWidget( self .centralwidget) # adds widgets to main window … |
This all means that we can create a new QMainWindow widget in our code with ...
1 | mainWindow = QMainWindow() |
... create an object of the UI_MainWindow class with
1 | ui = gui_main.Ui_MainWindow() |
... and then create the GUI for the main window by calling ui.setupUi(…) with mainWindow as the parameter:
1 | ui.setupUi(mainWindow) |
Whenever we need to access a widget created by setupUi(…), we can do so by using the expression
1 | ui.< object name of the widget> |
where the object name is the name we gave the widget in QT Designer, e.g.
1 | ui.queryTermLE |
for the QLineEdit widget we created for entering the query term.
Now we are going to develop the main code for our tool that imports the gui_main.py and gui_newshapefile.py files, sets up the application, and connects the different GUI elements with event handler functions and functions that realize the actual functionality of the tool. We will organize the code into several modules. In particular, we will keep the functions that realize the main functionality, such as querying the different web portals, creating a new shapefile, etc. in a separate Python script file called core_functions.py . These functions are completely independent of the GUI of our tool and the few global variables we will need, so we might want to use them in other projects. Separating the project cleanly into GUI dependent and GUI independent code fosters reusability of the GUI independent code. Overall, the project will consist of the following Python files:
In the following, we will focus on the code from main.py but we will start with a brief look at core_functions.py, so please download the file core_functions.py [15] and open it so that you can read the code for the different functions. Most of the functions defined in the script should be rather easy to understand from the comments in the code and from your experience with using arcpy to work with shapefiles. Here is an overview of the functions with a few additional explanations:
Now that you know the functions we have available for realizing the different operations that we will need, let’s develop the code for main.py together. Open a new file main.py in your IDE, then follow the steps listed on the next few pages.
We start by importing the different packages of the Python standard library and PyQt5 that we will need in this project. In addition, we import the gui_main.py and gui_newshapefile.py files so that the Ui_MainWindow and Ui_Dialog classes defined in them are available for creating the GUIs of the main window and dialog for creating a new shapefile, and of course the core_functions module. We are not importing arcpy here because we want the tool to be runnable even when arcpy is not available and that is why we defined the auxiliary function for testing its availability in core_functions.py. In addition, we are including some comments to define sections within the script for different purposes. We will fill in the code for these sections step-by-step in the following steps. At the very end, we already have the by-now-familiar code for showing the main window and starting the event processing loop of our application (even though we are not creating the application and main window objects yet).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 | import sys, csv from PyQt5.QtWidgets import QApplication, QMainWindow, QStyle, QFileDialog, QDialog, QMessageBox, QSizePolicy from PyQt5.QtGui import QStandardItemModel, QStandardItem, QDoubleValidator, QIntValidator from PyQt5.QtCore import QVariant from PyQt5.Qt import Qt try : from PyQt5.QtWebEngineWidgets import QWebEngineView as WebMapWidget except : from PyQt5.QtWebKitWidgets import QWebView as WebMapWidget import gui_main import gui_newshapefile import core_functions # ======================================= # GUI event handler and related functions # ======================================= #========================================== # create app and main window + dialog GUI # ========================================= #========================================== # connect signals #========================================== #================================== # initialize global variables #================================== #============================================ # test availability and if run as script tool #============================================ #======================================= # run app #======================================= mainWindow.show() sys.exit(app.exec_()) |
You may be wondering what is happening in lines 8 to 10. The reason for the try/except construct is because we are using a web view widget in the "Results" part of the GUI to display a Leaflet-based web map of the results. There have been some changes with regard to the web view widget over the last versions of QT5 with the old class QWebView becoming deprecated and a new class QWebEngineView being added to replace it. The purpose of the code is to use QWebEngineView if it is available (meaning the code is run with a newer version of PyQt5) and otherwise fall back to using QWebView. The alias we assign the import to (WebMapWidget) is used to make sure that in both cases the imported class and functions is available under the same name in our code.
If the application does not start and results in a "" error, you may have to pip install QtWebEngineWidgets in the Python Command Window (from earlier in the lesson) to install the package. Then comment out the try/except and adjust the from PyQt5.QtWebEngineWidgets import QWebEngineView as WebMapWidget line so it is correct. If this does not resolve the error, please let your instructor know.
In the next step, we add the code for creating the QApplication and the QMainWindow and QDialog objects for the main window and the dialog for creating a new shapefile with their respective GUIs. For this, please paste the following code into your script directly under the comment “# create app and main window + dialog GUI”:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | app = QApplication(sys.argv) # set up main window mainWindow = QMainWindow() ui = gui_main.Ui_MainWindow() ui.setupUi(mainWindow) ui.actionExit.setIcon(app.style().standardIcon(QStyle.SP_DialogCancelButton)) ui.layerRefreshTB.setIcon(app.style().standardIcon(QStyle.SP_BrowserReload)) ui.directInputLatLE.setValidator(QDoubleValidator()) ui.directInputLonLE.setValidator(QDoubleValidator()) ui.nominatimLimitLE.setValidator(QIntValidator()) ui.geonamesLimitLE.setValidator(QIntValidator()) mapWV = WebMapWidget() mapWV.page().profile().setHttpAcceptLanguage( "en-US" ) mapWV.setHtml(core_functions.webMapFromDictionaryList([])) ui.resultsListAndMapHBL.addWidget(mapWV) mapWV.setFixedSize( 300 , 200 ) mapWV.setSizePolicy(QSizePolicy(QSizePolicy.Fixed,QSizePolicy.Fixed)) # set up new shapefile dialog createShapefileDialog = QDialog(mainWindow) createShapefileDialog_ui = gui_newshapefile.Ui_Dialog() createShapefileDialog_ui.setupUi(createShapefileDialog) |
In lines 4 to 6, we are creating the mainWindow object and then its GUI by calling the ui.SetupUi(…) method of an object we created from the Ui_MainWindow class defined in gui_main.py. The same happens in lines 23 to 25 for the dialog box for creating a new shapefile. The rest of the code is for creating some additional elements or setting some properties of GUI elements that we couldn’t take care of in QT Designer:
We now add some code to initialize some global variables that we will need. Please add the following code directly under the comment “# initialize global variables”:
1 2 3 4 5 6 7 8 9 10 11 | # dictionary mapping tabs from services tab widget to event handler functions queryHandler = { ui.nominatimTab: runNominatimQuery, ui.geonamesTab: runGeonamesQuery, ui.directInputTab: runDirectInput } # dictionary mapping tabs from add feature tab widget to event handler functions addFeaturesHandler = { ui.layerTab: addFeaturesToLayer, ui.shapefileTab: addFeaturesToShapefile, ui.csvTab: addFeaturesToCSV } result = [] # global variable for storing query results as list of dictionaries arcValidLayers = {} # dictionary mapping layer names to layer objects arcpyAvailable = False # indicates whether is available for import runningAsScriptTool = False # indicates whether script is run as script tool inside ArcGIS |
The first two variables defined, queryHandler and addFeaturesHandler, are dictionaries that contain the information of which event handler functions should be called when the “Run query” button and “Add features” button are clicked, respectively, depending on which of the tabs of the two different tab widgets are currently selected. Line 2, for instance, says that if currently the tab ui.nominatimTab is open in the Services section, then the function runNominatimQuery(…) should be called. So far we have not defined that function yet, hence, you will not be able to run the program at the moment but it shows you that functions in Python are treated like other kinds of objects, meaning they can, for instance, be stored in a dictionary. You will hear more about this in Lesson 3.
The other global variables we define in this piece of code are for keeping track of the results currently displayed in the Results list view widget of our GUI, of the currently open Point layers in ArcGIS when being run as a script tool, of whether the arcpy module is available, and of whether the program is being run as a script tool or not. We will add code to initialize the last two of these variables correctly in a moment
To now initialize the variables arcpyAvailable and runningAsScriptTool correctly and potentially disable some GUI elements, please add the following code directly under the comment “# test availability and if run as script tool”:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | arcpyAvailable = core_functions.importArcpyIfAvailable() if not arcpyAvailable: ui.addFeaturesTW.setCurrentWidget(ui.csvTab) ui.addFeaturesTW.setTabEnabled(ui.addFeaturesTW.indexOf(ui.shapefileTab), False ) ui.addFeaturesTW.setTabEnabled(ui.addFeaturesTW.indexOf(ui.layerTab), False ) ui.statusbar.showMessage( 'arcpy not available. Adding to shapefiles and layers has been disabled.' ) else : import arcpy if core_functions.runningAsScriptTool(): runningAsScriptTool = True updateLayers() else : ui.addFeaturesTW.setTabEnabled(ui.addFeaturesTW.indexOf(ui.layerTab), False ) ui.statusbar.showMessage(ui.statusbar.currentMessage() + 'Not running as a script tool. Adding to layer has been disabled.' ) |
What happens here is that we first use importArcpyIfAvailable() from the core_functions module to check whether we can import arcpy. If this is not the case, we make the “CSV” tab the current and only selectable tab from the Add Features tab widget by disabling the “Shapefile” and “Layer” tabs. In the else-part (so if arcpy is available), we further use the runningAsScriptTool() function to check if the program is being run as a script tool inside ArcGIS. If not, we just disable the “Layer” tab and make the “Shapefile” tab the currently selected one. In addition, some warning message in the statusbar is produced if either the “Layer” or both the “Layer” and “Shapefile” tabs had to be disabled.
Time to get back to the GUI and implement the required event handler functions for the different GUI elements, in particular the different buttons. This will be quite a bit of code, so we will go through the event handler functions individually. Please add each function in the order they are listed below to the section labeled “# GUI event handler and related functions”:
1 2 3 4 5 6 7 | # query and direct input functions def runQuery(): """run one of the different query services based on which tab is currently open""" queryString = ui.queryTermLE.text() activeTab = ui.queryServicesTW.currentWidget() queryHandler[activeTab](queryString) # call a function from the dictionary in queryHandler |
This is the event handler function for when the “Run query” button is clicked. We already mentioned the global variable queryHandler that maps tab widgets to functions. So we here first get the text the user entered into the queryTermLE widget, then get the currently selected tab from the queryServicesTW tab widget, and finally in the last line we call the corresponding function for querying Nominatim, GeoNames, or providing direct input. These functions still need to be defined.
1 2 3 4 5 6 7 8 9 | def setListViewFromResult(r): """populate list view with checkable entries created from result list in r""" m = QStandardItemModel() for item in r: item = QStandardItem(item[ 'name' ] + ' (' + item[ 'lat' ] + ',' + item[ 'lon' ] + ')' ) item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled) item.setData(QVariant(Qt.Checked), Qt.CheckStateRole) m.appendRow(item) ui.resultsLV.setModel(m) |
setListViewFromResult(…) is an auxiliary function for populating the resultsLV list view widget with the result from a query or a direct input. It will be called from the functions for querying the different web services or providing direct input that will be defined next. The given parameter needs to contain a list of dictionaries with name, lat, and lon properties that each represent one item from the result. The for-loop goes through these items and creates list items for the QStandardItemModel from them. Finally, the resulting model is used as the list model for the resultsLV widget.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | def runNominatimQuery(query): """query nominatim and update list view and web map with results""" ui.statusbar.showMessage( 'Querying Nominatim... please wait!' ) country = ui.nominatimCountryCodeLE.text() if ui.nominatimCountryCodeCB.isChecked() else '' limit = ui.nominatimLimitLE.text() try : items = core_functions.queryNominatim(query, limit, country) # run query # create result list from JSON response and store in global variable result global result result = [( lambda x: { 'name' : x[ 'display_name' ], 'lat' : x[ 'lat' ], 'lon' : x[ 'lon' ]})(i) for i in items] # update list view and map with results setListViewFromResult(result) mapWV.setHtml(core_functions.webMapFromDictionaryList(result)) ui.statusbar.showMessage( 'Querying done, ' + str ( len (result)) + ' results returned!' ) except Exception as e: QMessageBox.information(mainWindow, 'Operation failed' , 'Querying Nominatim failed with ' + str (e.__class__) + ': ' + str (e), QMessageBox.Ok ) ui.statusbar.clearMessage() |
This is the function that will be called from function runQuery() defined above when the currently selected “Service” tab is the Nominatim tab. It gathers the required information from the line edit widgets on the Nominatim query tab, taking into account the status of the corresponding checkboxes for the optional elements (using the "... if ... else ..." ternary operator). Then it calls the queryNominatim(…) function from the core_functions module to perform the actual querying (line 9) and translates the returned JSON list into a result list of dictionaries with name, lat, and lon properties that will be stored in the global variable result. Note that we are using list comprehension here to realize this translation of one list into another. The resultLV list view and mapWV web map widget will then be updated accordingly. This happens inside a try-except block to catch exceptions when something goes wrong with querying the web service or interpreting the results. Statusbar messages are used to keep the user informed about the progress and a message box is shown if an exceptions occurs to inform the user.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | def runGeonamesQuery(query): """query geonames and update list view and web map with results""" ui.statusbar.showMessage( 'Querying GeoNames... please wait!' ) username = ui.geonamesUsernameLE.text() country = ui.geonamesCountryCodeLE.text() if ui.geonamesCountryCodeCB.isChecked() else '' fclass = ui.geonamesFeatureClassLE.text() if ui.geonamesFeatureClassCB.isChecked() else '' limit = ui.geonamesLimitLE.text() try : items = core_functions.queryGeonames(query, limit, username, country, fclass ) # run query # create result list from JSON response and store in global variable result global result result = [( lambda x: { 'name' : x[ 'toponymName' ], 'lat' : x[ 'lat' ], 'lon' : x[ 'lng' ]})(i) for i in items] # update list view and map with results setListViewFromResult(result) mapWV.setHtml(core_functions.webMapFromDictionaryList(result)) ui.statusbar.showMessage( 'Querying done, ' + str ( len (result)) + ' results returned!' ) except Exception as e: QMessageBox.information(mainWindow, 'Operation failed' , 'Querying GeoNames failed with ' + str (e.__class__) + ': ' + str (e), QMessageBox.Ok) ui.statusbar.clearMessage() |
This function that will be called from runQuery() if the currently selected “Service” tab is the GeoNames tab works exactly like the previous function for Nominatim, just the query parameters extracted in lines 5 to 8 are different and the translation into a result list looks a little bit different because GeoNames uses other property names ("toponymName" instead of "display_name" and "lng" instead of "lon").
1 2 3 4 5 6 7 8 9 10 11 12 13 | def runDirectInput(query): """create single feature and update list view and web map with results""" name = ui.directInputNameLE.text() lon = ui.directInputLonLE.text() lat = ui.directInputLatLE.text() # create result list with single feature and store in global variable result global result result = [{ 'name' : name, 'lat' : lat, 'lon' : lon }] # update list view and map with results setListViewFromResult(result) mapWV.setHtml(core_functions.webMapFromDictionaryList(result)) ui.statusbar.showMessage( 'Direct input has been added to results list!' ) |
This function will be called from runQuery() if the currently selected “Service” tab is the Direct Input tab. Again, we are collecting the relevant information from the input widgets (line 3 to 5) but here we directly produce the result consisting of just a single item (line 9). The rest works in the same way as in the previous two functions.
These were the functions required for the query section of our tool. So we can now move on to the Results section where we just need the three event handler functions for the three buttons located below the list view widget.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | # list view selection functions def selectAll(): """select all items of the list view widget""" for i in range (ui.resultsLV.model().rowCount()): ui.resultsLV.model().item(i).setCheckState(Qt.Checked) def clearSelection(): """deselect all items of the list view widget""" for i in range (ui.resultsLV.model().rowCount()): ui.resultsLV.model().item(i).setCheckState(Qt.Unchecked) def invertSelection(): """invert current selection of the list view widget""" for i in range (ui.resultsLV.model().rowCount()): currentValue = ui.resultsLV.model().item(i).checkState() ui.resultsLV.model().item(i).setCheckState(Qt.Checked if currentValue = = Qt.Unchecked else Qt.Unchecked) |
These three functions all work very similarly: We go through all items in the list model underlying the resultsLV list view widget. In selectAll(), the check state of each item is set to “Checked”, while in clearSelection() it is set to “Unchecked” for each item. In invertSelection(), we take the item’s current state and either change it from “Checked” to “Unchecked” or vice versa (using the ternary "... if ... else ..." operator once more).
1 2 3 4 5 6 | # adding features functions def addFeatures(): """run one of the different functions for adding features based on which tab is currently open""" activeTab = ui.addFeaturesTW.currentWidget() addFeaturesHandler[activeTab]() # call a function from the dictionary in addFeatureHandler |
We have now arrived at the last row of our main window graphical interface for adding the selected result features to a layer, shapefile, or csv file. The addFeatures() function corresponds to the runQuery() function from the beginning in that it invokes the right function depending on which tab of the addFeaturesTW tab widget is currently selected. This is based on the global variable addFeaturesHandler that map tabs to functions.
1 2 3 4 5 | def updateShapefileFieldCB(): """update shapefileFieldCB combo box with field names based on shapefile name""" ui.shapefileFieldCB.clear() fileName = ui.shapefileAddLE.text() ui.shapefileFieldCB.addItems(core_functions.getValidFieldsForShapefile(fileName)) |
updateShapefileFieldCB() is an auxiliary function for updating the content of the shapefileFieldCB combo box whenever the name of the shapefile in the shapefileAddLE line edit widget changes so that the combo always displays the editable string fields of that shapefile.
1 2 3 4 5 6 | def selectShapefile(): """open file dialog to select exising shapefile and if accepted, update GUI accordingly""" fileName, _ = QFileDialog.getOpenFileName(mainWindow, "Select shapefile" , " "," Shapefile ( * .shp)") if fileName: ui.shapefileAddLE.setText(fileName) updateShapefileFieldCB() |
When the shapefileOpenFileTB tool button is clicked, we want to display a file dialog for picking the shapefile. Opening the dialog and processing the result happens in the function selectShapefile(). When a file name is returned (meaning the dialog wasn’t cancelled by the user), the name is put into the shapefileAddLE line edit field and updateShapefieldCB() is called to update the combo box with the field names of that file.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | def addFeaturesToShapefile(): """add selected features from list view to shapefile""" fieldName = ui.shapefileFieldCB.currentText() fileName = ui.shapefileAddLE.text() ui.statusbar.showMessage( 'Adding entities has started... please wait!' ) try : with arcpy.da.InsertCursor(fileName, ( "SHAPE@" ,fieldName)) as cursor: for i in range (ui.resultsLV.model().rowCount()): # go through all items in list view if ui.resultsLV.model().item(i).checkState() = = Qt.Checked: point = arcpy.Point( result[i][ 'lon' ], result[i][ 'lat' ]) cursor.insertRow( (point, result[i][ 'name' ][: 30 ]) ) # name shortened to 30 chars ui.statusbar.showMessage( 'Adding entities has finished.' ) except Exception as e: QMessageBox.information(mainWindow, 'Operation failed' , 'Writing to shapefile failed with ' + str (e.__class__) + ': ' + str (e), QMessageBox.Ok ) ui.statusbar.clearMessage() |
This function contains the code for writing the selected features from the results list in global variable result to the shapefile with the help of an arcpy insert cursor. We first read the relevant information from the shapefileAddLE and shapefileFieldCB widgets and then in the for-loop go through the items in the resultsLV list view to see whether they are checked or not. If an item is checked, an arcpy.Point object is created from the corresponding dictionary in variable result and then written to the shapefile together with the name of the location. Statusbar messages are used to inform on the progress or a message box will be shown if an exception occurs while trying to write to the shapefile.
1 2 3 4 5 6 7 8 9 10 | def updateLayerFieldCB(): """update layerFieldCB combo box with field names based on selected layer""" ui.layerFieldCB.clear() layer = ui.layerPickLayerCB.currentText() try : ui.layerFieldCB.addItems(core_functions.getStringFieldsForDescribeObject(arcpy.Describe(arcValidLayers[layer]))) except Exception as e: QMessageBox.information(mainWindow, 'Operation failed' , 'Obtaining field list failed with ' + str (e.__class__) + ': ' + str (e), QMessageBox.Ok ) ui.statusbar.clearMessage() |
This is the corresponding function to updateShapefileFieldCB() but for the layerFieldCB combo box widget part of the Layer tab.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | def updateLayers(): """refresh layers in global variable arcValidLayers and layerPickLayerCB combo box""" layers = [] global arcValidLayers arcValidLayers = {} ui.layerPickLayerCB.clear() ui.layerFieldCB.clear() try : layers = core_functions.getPointLayersFromArcGIS() # get all point layers for l in layers: # add layers to arcValidLayers and GUI arcValidLayers[l.name] = l ui.layerPickLayerCB.addItem(l.name) updateLayerFieldCB() except Exception as e: QMessageBox.information(mainWindow, 'Operation failed' , 'Obtaining layer list from ArcGIS failed with ' + str (e.__class__) + ': ' + str (e), QMessageBox.Ok ) ui.statusbar.clearMessage() ui.shapefileFieldCB.clear() |
This function is for populating the layerPickLayerCB and arcValidLayer global variable with the Point vector layers currently open in ArcGIS. It uses the getPointLayersFrom ArcGIS() function from core_functions.py to get the list of layers and then in the for-loop stores the layer objects under their layer name in the arcValidLayers dictionary and just the names as items in the combo box. If something goes wrong with getting the layers from ArcGIS, the corresponding exception will be caught and a message box will warn the user about the failure of the operation.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | def addFeaturesToLayer(): """add selected features from list view to layer""" layer = ui.layerPickLayerCB.currentText(); fieldName = ui.layerFieldCB.currentText() ui.statusbar.showMessage( 'Adding entities has started... please wait!' ) try : with arcpy.da.InsertCursor(arcValidLayers[layer], ( "SHAPE@" ,fieldName)) as cursor: for i in range (ui.resultsLV.model().rowCount()): # go through all items in list view if ui.resultsLV.model().item(i).checkState() = = Qt.Checked: point = arcpy.Point( float (result[i][ 'lon' ]), float (result[i][ 'lat' ])) cursor.insertRow( (point, result[i][ 'name' ][: 30 ]) ) # name shortened to 30 chars ui.statusbar.showMessage( 'Adding entities has finished.' ) except Exception as e: QMessageBox.information(mainWindow, 'Operation failed' , 'Writing to layer failed with ' + str (e.__class__) + ': ' + str (e), QMessageBox.Ok ) ui.statusbar.clearMessage() |
This is the analogous function to the previously defined function addFeaturesToShapefile() but for a currently open layer and based on the information in the widgets of the Layer tab.
1 2 3 4 5 | def selectCSV(): """open file dialog to select exising csv/text file and if accepted, update GUI accordingly""" fileName, _ = QFileDialog.getOpenFileName(mainWindow, "Select CSV file" , " "," ( * . * )") if fileName: ui.csvAddToFileLE.setText(fileName) |
Similarly to selectShapefile(), this function opens a file dialog to select a csv file to append the features to.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | def addFeaturesToCSV(): """add selected features from list view to csv/text file""" fileName = ui.csvAddToFileLE.text() ui.statusbar.showMessage( 'Adding entities has started... please wait!' ) try : with open (fileName, 'a' , newline = '') as csvfile: csvWriter = csv.writer(csvfile) for i in range (ui.resultsLV.model().rowCount()): # go through all items in list view if ui.resultsLV.model().item(i).checkState() = = Qt.Checked: csvWriter.writerow( [ result[i][ 'name' ], result[i][ 'lon' ], result[i][ 'lat' ] ]) ui.statusbar.showMessage( 'Adding entities has finished.' ) except Exception as e: QMessageBox.information(mainWindow, 'Operation failed' , 'Writing to csv file failed with ' + str (e.__class__) + ': ' + str (e), QMessageBox.Ok ) ui.statusbar.clearMessage() |
Working similarly to addFeaturesToShapefile() and addFeaturesToLayer(), this function writes the selected features as rows to a text file using the csv.writer class from the csv module of the Python standard library.
1 2 3 4 5 | def selectNewShapefile(): """open file dialog to creaete new shapefile and if accepted, update GUI accordingly""" fileName, _ = QFileDialog.getSaveFileName(mainWindow, "Save new shapefile as" , " "," Shapefile ( * .shp)") if fileName: createShapefileDialog_ui.newShapefileLE.setText(fileName) |
The final two functions are for creating a new shapefile. selectNewShapefile() is called when the newShapefileBrowseTB button that is part of the dialog box for creating a new shapefile is clicked and displays a file dialog for saving a file under a new name. The chosen name is used to set the text of the newShapefileBrowseTB line edit widget.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | def createNewShapefile(): """create new shapefile and adds field based on info in dialog GUI""" if createShapefileDialog.exec_() = = QDialog.Accepted: file = createShapefileDialog_ui.newShapefileLE.text() field = createShapefileDialog_ui.fieldForNameLE.text() try : core_functions.createPointWGS1984Shapefile( file ,field) ui.shapefileAddLE.setText( file ) updateShapefileFieldCB() ui.shapefileFieldCB.setCurrentIndex(ui.shapefileFieldCB.findText(field)) ui.statusbar.showMessage( 'New shapefile has been created.' ) except Exception as e: QMessageBox.information(mainWindow, 'Operation failed' , 'Creating new shapefile failed with ' + str (e.__class__) + ': ' + str (e), QMessageBox.Ok ) ui.statusbar.clearMessage() ui.shapefileFieldCB.clear() |
This function is called when the “Create new shapefile” button on the Shapefile tab is clicked. It first displays the createShapefileDialog dialog box modally by calling its exec_() method. If the dialog is accepted (= closed by clicking Ok), the function creates the new shapefile with the help of the createPointWGS1984Shapefile() function from core_functions.py and based on the input fields in the dialog box for creating a new shapefile (newShapefileLE and fieldForNameLE). If no exception is raised, the file name and field name from the dialog box will be used to change the text of the shapefileAddLE line edit widget and the shapefileFieldCB combo box.
At this point, we are almost done. The last thing that has to happen is connecting the widgets’ relevant signals to the corresponding slots or event handler functions. For this, please add the following code under the comment “# connect signals”:
1 2 3 4 5 6 7 8 9 10 11 12 13 | ui.runQueryPB.clicked.connect(runQuery) ui.resultsClearSelectionPB.clicked.connect(clearSelection) ui.resultsSelectAllPB.clicked.connect(selectAll) ui.resultsInvertSelectionPB.clicked.connect(invertSelection) ui.shapefileOpenFileTB.clicked.connect(selectShapefile) ui.addFeatureAddPB.clicked.connect(addFeatures) ui.shapefileCreateNewPB.clicked.connect(createNewShapefile) ui.csvOpenFileTB.clicked.connect(selectCSV) ui.layerRefreshTB.clicked.connect(updateLayers) ui.shapefileAddLE.editingFinished.connect(updateShapefileFieldCB) ui.layerPickLayerCB.activated.connect(updateLayerFieldCB) createShapefileDialog_ui.newShapefileBrowseTB.clicked.connect(selectNewShapefile) |
Lines 1 to 9 and line 13 all connect “clicked” signals of different buttons in our GUI to the different event handler functions defined previously and should be easy to understand. In line 10, the “editingFinished” signal of the text field for entering the name of a shapefile is connected with the updateShapefileFieldCB() function so that, whenever the name of the shapefile is changed, the list of the fields in the combo box is updated accordingly. In line 11, we connect the “activated” signal of the combo box for selecting a layer with the upateLayerFields() function. As a result, the second combo box with the field names will be updated whenever the layer selected in the first combo box on the Layer tab is changed.
That’s it. The program is finished and can be tested and used either as a standalone application (writing features either to a shapefile or to a .csv file) or as an ArcGIS script tool. Give it a try yourself and think about which parts of the code are being executed when performing different operations. In case you want to run it as a script tool inside ArcGIS Pro, setting up the script tool for it should be straightforward. You just have to create a new script tool without specifying any parameters and provide the path to the main.py script for the source. If you have any problems running the code with your own script, the entire code can be downloaded via this link to the Locations from Web Services Complete zip file [17]. If something in the code above is unclear to you, please ask about it on the course forums.
Obviously, the tool is still somewhat basic and could be extended in many ways including:
Moreover, while we included some basic error handling with try-except, the program is not completely bullet proof and in some cases it would be desirable to provide more direct and specific feedback to the user, for instance if the user enters something into the feature class field of the GeoNames query tab that is not a valid feature class code. We are also quietly assuming that the shapefile or layer we are adding to is using a WGS1984 geographical coordinate system. Adding reprojection of the input features to the CRS of the destination would certainly be a good thing to do.
Still, the tool can be useful for creating point feature classes of locations of interest very quickly and in a rather convenient way. More importantly, this walkthrough should have provided you with a better understanding of how to create Python programs with a GUI by roughly separating the code into a part for setting up the GUI elements, a part for realizing the actual functionality (in this case the part defining the different event handler functions (GUI dependent) with the help of the core functions from core_functions.py (GUI independent)), and a part that makes the connection between the other two parts based on GUI events. You will practice creating GUIs and PyQt5 based Python programs yourself in this lesson’s homework assignment and also again in lesson 4. But for now, we will continue with looking at another aspect of creating and publishing Python applications, namely that of package management and packaging Python programs so that they can easily be shared with others.
Links
[1] https://www.e-education.psu.edu/geog489/node/2331
[2] https://leafletjs.com/
[3] https://www.openstreetmap.org
[4] https://nominatim.openstreetmap.org/
[5] https://wiki.openstreetmap.org/wiki/Nominatim
[6] https://nominatim.openstreetmap.org/search?q=Eiffel%20tower%2c%20France&format=json
[7] https://nominatim.openstreetmap.org/search?q=London&format=json&countrycodes=CA&limit=1
[8] http://www.geonames.org/
[9] http://www.geonames.org/export/geonames-search.html
[10] http://api.geonames.org/searchJSON?name=Springfield&maxRows=10&username=demo
[11] http://www.geonames.org/export/codes.html
[12] https://www.e-education.psu.edu/geog489/1871
[13] https://www.e-education.psu.edu/geog489/sites/www.e-education.psu.edu.geog489/files/downloads/GEOG489_node_2_6_2.zip
[14] https://www.e-education.psu.edu/geog489/node/2284
[15] https://www.e-education.psu.edu/geog489/sites/www.e-education.psu.edu.geog489/files/downloads/core_functions_Sept2023.zip
[16] https://www.e-education.psu.edu/geog489/2233
[17] https://www.e-education.psu.edu/geog489/sites/www.e-education.psu.edu.geog489/files/downloads/LocationsFromWebServicesComplete_Sept2023.zip