Constructing the ToolValidator Class to Validate Inputs in a Python Script Tool containing Multiple Tools

1354
8
04-11-2023 04:09 PM
ZacharyUhlmann1
Occasional Contributor III

Fundamentally confused.  I made a Python Script Toolbox with multiple tools (hopefully proper terminology):

script_tool.jpg 

Each tool works fine, and the basic Python code/structure is as shown below.  NOTE! I am only showing one of the Tools (metadata_mcm) and not update_attr or xml_element_csv_creator - but they are the same in basic structure.  Skip to my questions below before diving into code.

 

class Toolbox(object):
    def __init__(self):
        """Define the toolbox (the name of the toolbox is the name of the
        .pyt file)."""
        self.label = "Toolbox"
        self.alias = "toolbox"

        # List of tool classes associated with this toolbox
        self.tools = [metadata_mcm, create_xml_element_csv, update_attr]

class ToolValidator(object):
  """Class for validating a tool's parameter values and controlling
  the behavior of the tool's dialog."""

  def __init__(self):
    """Setup arcpy and the list of tool parameters."""
    self.params = arcpy.GetParameterInfo()

  def initializeParameters(self):
    """Refine the properties of a tool's parameters.  This method is
    called when the tool is opened."""
    return

  def updateParameters(self):
    """Modify the values and properties of parameters before internal
    validation is performed.  This method is called whenever a parameter
    has been changed."""

    return

  def updateMessages(self):
    """Modify the messages created by internal validation for each tool
    parameter.  This method is called after internal validation."""
    self.params[1].clearMessage()
    if self.params[1].value is None:
        self.params[1].clearMessage()
    else:
        if os.path.exists(self.params[1]):
            self.params[1].setErrorMessage('output path already exists')
        else:
            self.params[1].clearMessage()

    return
class create_xml_element_csv(object):
    def __init__(self):
        self.label = "xml element csv creator"
        self.desciption = "create inventory of xml elements relevant to fields"
        self.canRunInBackground = False

    def getParameterInfo(self):
        """Define parameter definitions"""
        param0 = arcpy.Parameter(
            displayName="Input Feature",
            name="fc_in",
            datatype="Layer",
            parameterType="Required",
            direction="Input")
        param1 = arcpy.Parameter(
            displayName="Path/to/file.csv",
            name="fp_csv",
            datatype="DEFile",
            parameterType="Required",
            direction="Input")
        params = []
        params.append(param0)
        params.append(param1)
        return params

    def isLicensed(self):
        """Set whether tool is licensed to execute."""
        return True

    def updateParameters(self, parameters):
        """Modify the values and properties of parameters before internal
        validation is performed.  This method is called whenever a parameter
        has been changed."""
        return

    def updateMessages(self, parameters):
        """Modify the messages created by internal validation for each tool
        parameter.  This method is called after internal validation."""

    def execute(self, parameters, messages):
        """The source code of the tool."""
        fc = parameters[0]
        desc = arcpy.Describe(fc)
        fp_fc = desc.featureClass.catalogPath
        fp_csv = parameters[1].valueAsText
        st0, st1 = os.path.splitext(fp_csv)
        # csv
        if st1 == '.csv':
            pass
        else:
            fp_csv = '{}.csv'.format(fp_csv)

        flds = [f.name for f in arcpy.ListFields(fp_fc)]
        vals = np.column_stack([flds, [None] * len(flds), [None] * len(flds), [None] * len(flds)])
        # attrdef = definition
        # attrdefs = definition source
        cn = ['attrlabl', 'attralias', 'attrdef', 'attrdefs']
        df_fields = pd.DataFrame(vals, columns=cn)
        df_fields.to_csv(fp_csv)
        return

 

I don't understand a couple things:

1) Can the ToolValidator Class work with multiple tools?  It's odd that I have essentially hard-coded the paramaters positionally in updateMessages for ALL subsequent tools.  I want to ensure that the output file does not exist prior to running tool.  This is argument 1 for create_xml_element_csv but doesn't exist for the two other tools.  So can I create a ToolValidator Class and only apply the validation to one tool in the toolbox?

2) Is my syntax within ToolValidator/updateMessages method correct?  Found this syntax from a StackExchange response.

The tools run BUT the validation of

if os.path.exists(self.params[1]):
self.params[1].setErrorMessage('output path already exists')

is not being performed.  I can pass an argument of a path that exists, and no warming is issued, tool overwrites the existing file.  No bueno.

Thanks,

Zach

Tags (2)
0 Kudos
8 Replies
Luke_Pinner
MVP Regular Contributor

The ToolValidator class is used by script tools in custom toolboxes (legacy .tbx or new .atbx), not by Python Toolboxes (.pyt).

In python toolboxes (.pyt) all the validation is done is the updateMessages and updateParameters methods of the tool class itself 

Customize tool behavior in a Python toolbox

 

ZacharyUhlmann1
Occasional Contributor III

Hi @Luke_Pinner - I appreciate your response.  I suspected that may be the case.  ESRI's documentation can be really frustrating.  I scoured that link you included and all links therein prior to posting.  Reread them just now and it's still really confusing.  FYI - I am experienced with Object Oriented Programming and Classes.  None of the links on that page show me a fleshed out implementation of parameter validation:

1) understanding-validation-in-script-tools 

2) programming-a-toolvalidator-class which has instructions on constructing the ToolValidator Class, which I thought were not applicable with the Python Toolbox

So! In the link you sent, they provide this example (below).  But I'm confused.  What is the parameters argument?  I imagine that is params?  And more confusingly, what is parameters[6]? 

if not parameters[6].altered

The example preceding this has only 3 parameters, so 0,1,2 indices.  

 

 

def updateParameters(self, parameters):
    # Set the default distance threshold to 1/100 of the larger of the width
    #  or height of the extent of the input features.  Do not set if there is no 
    #  input dataset yet, or the user has set a specific distance (Altered is true).
    #
    if parameters[0].valueAsText:
        if not parameters[6].altered:
            extent = arcpy.Describe(parameters[0]).extent
        if extent.width > extent.height:
            parameters[6].value = extent.width / 100
        else:
            parameters[6].value = extent.height / 100

    return

 

 

 

I'm REALLY appreciative of your help!  Just confused and critical of the ESRI documentation that always seems to weave in and out of Pro vs Desktop vs whatever.  Check reply below - I did solve this.  It could be ugly.  Suggestions welcome...

0 Kudos
AlfredBaldenweck
MVP Regular Contributor
  • "parameters" is what params from getParameterInfo() is called in as. Yeah, it'd be better if they were the same word, but whatever. You can probably change one to the other for consistency; I haven't tried it, but I bet it'd be fine.

  • In the case you're referencing, there are at least 7 parameters in their list of parameters. It's checking to see if there's a value in the first parameter (parameters[0]) before validating the seventh parameter (parameters[6]).
    • Generally, each example on a documentation page lives in a vaccuum and is not related to any other example unless they're in the same section. So any example in the values section is probably related to others in that section, but not to examples in the depedencies section.

You don't need a separate Tool Validator class because its stuff is already inside the tool already. Its functionality is used on a per-tool basis in their updateParameters() and updateMessages().  (See lines 47 and 64 below)

Use the Validator reference page as you were already but populate inside the tool instead and you should be golden.

I find it helpful to assign each parameter in the list as a variable during validation so I can keep track of what I'm doing to each one, as well as making it easier to change their numbering later if I need.

Below is a pared down example of one of the tools in one of my PYTs.

Each tool is listed in self.tools of the Toolbox class, and under each tool is the validation. 

class Toolbox(object):
    def __init__(self):
        """Define the toolbox (the name of the toolbox is the name of the
        .pyt file)."""
        self.label = "Toolbox"
        self.alias = ""

        # List of tool classes associated with this toolbox
        self.tools = [ExportPhotos]

class ExportPhotos(object): # The tool
    def __init__(self):
        """Define the tool (tool name is the name of the class)."""
        self.label = "Export Photos"
        self.description = ""
        self.canRunInBackground = False

    def getParameterInfo(self):
        """Define parameter definitions"""
        param0 = arcpy.Parameter(
                    displayName="GDBs or Feature Classes?",
                    name="GDBs_or_Feature_Classes?",
                    datatype="GPString",
                    parameterType="Required",
                    direction="Input")
        param0.filter.type = "ValueList"
        param0.filter.list = ["GDBs", "Feature Classes"]
        param0.value = "GDBs"
        
        param1 = arcpy.Parameter(
                    displayName="Input GDBs",
                    name="Input_GDBs",
                    datatype="DEWorkspace",
                    parameterType="Optional",
                    direction="Input",
                    multiValue=True)
                    
        param2 = arcpy.Parameter(
                    displayName="Input Feature Classes",
                    name="Input_FCs",
                    datatype="GPFeatureLayer",
                    parameterType="Optional",
                    direction="Input",
                    multiValue=True)
        params = [param0, param1, param2) 
        return params

    def updateParameters(self, parameters):
        """Modify the values and properties of parameters before internal
        validation is performed.  This method is called whenever a parameter
        has been changed."""
        gdbOrFC = parameters[0] #param0
        inGDBS = parameters[1] #param1
        inFCs = parameters[2] #param2
        
        #Determines if inFCs or inGDBs is visible
        if gdbOrFC.value== "GDBs":
            inGDBS.enabled = True #inGDBS
            inFCs.enabled = False #inFCs        
        else:
            inGDBS.enabled = False
            inFCs.enabled = True 
        return

    def updateMessages(self, parameters):
        """Modify the messages created by internal validation for each tool
        parameter.  This method is called after internal validation."""
        gdbOrFC = parameters[0] #param0
        inGDBS = parameters[1] #param1
        inFCs = parameters[2] #param2
        
        #Set inGDBs to pretend it's required
        if (gdbOrFC.value == "GDBs") and (inGDBS.value is None):
            inGDBS.setIDMessage('ERROR', 735)
        return

 

0 Kudos
AlfredBaldenweck
MVP Regular Contributor

Another thing is that each tool has an updateMessages function. Normally validation is done on a per-tool basis using that tool's updateParameters and updateMessages. 

0 Kudos
ZacharyUhlmann1
Occasional Contributor III

Tentatively found a solution.  Any alternatives to the conditional logic: if parameters[1].value would be greatly appreciated!  But it works.  Code is commented to explain why I did this.  

    def updateMessages(self, parameters):
        """Modify the messages created by internal validation for each tool
        parameter.  This method is called after internal validation."""
        # This if statement required or else error message will for parameter[0]
        # "TypeError: stat: path should be string, bytes, os.PathLike or integer, not NoneType".
        # Apparently this method will run through ALL parameters right off the bat.  So add
        # conditional statements to ensure validation only performed on target parameters i.e. parameter[1] in this case
        if parameters[1].value:
            fp_csv = parameters[1].valueAsText
            if os.path.exists(fp_csv):
                parameters[1].setErrorMessage('output path already exists')
            else:
                parameters[1].clearMessage()
        return

Note that when an existing path/to/file.csv is passed for argument 2 (parameters[1]), the message will display:

script_tool_2.jpg

0 Kudos
DonMorrison1
Occasional Contributor III

I don't like hard coding the array indexes since they are a pain to adjust if you move around the order of the parameters. So I wrote a function to get them by name and call that at the start of the update and execute functions. 

 

# Find the parameter by name (which is set in getParametmerInfo)
def get_parm (parms, name, enforce_not_null = False):
    for parm in parms:
        if parm.name == name:
            if enforce_not_null and (parm.value is None or parm.valueAsText == ''):
                raise Exception ("'%s' has an invalid value" % (parm.displayName))
            return parm 

# At the start of update and execute functions, retrieve the relevant parameters by name instead of index
def execute (self, parameters):
        gdbOrFC = get_parm (parameters, "GDBs_or_Feature_Classes?")
        inGDBS = get_parm (parameters, "Input_GDBs")
        inFCs = get_parm (parameters, "Input_FCs")

 

 

ZacharyUhlmann1
Occasional Contributor III

Perhaps a new post is in order.  Do either of you have experience defining an argument as a DETextFile or DEFile?  For another Python Toolbox built on the same template you all helped me with (thanks!), I want to pass a path/to/csv as an argument.  Spent the hour fleshing out, copying and pasting the .pyt.  Looks good.  Tool opens, but when I select (or try to select) the input csv in both the third and fourth arguments, it doesn't load - argument input box(es) stay blank.  I've tried manually copying the path/to/file.csv into the argument, csv GIS attribute table in this case.  Same thing. 

DEfolder_pytoolbox.jpg

 

 

 

 

 

 

 

 

 

When I try running the tool I, an error is thrown about NoneType as would be expected.  My first instinct is to think an ESRI bug, but I'll withhold judgement until digging.  Just trying to pass a file as an argument.

0 Kudos
AlfredBaldenweck
MVP Regular Contributor

Super late response, but I had the same problem yesterday and it was because my two parameters had the same name.

0 Kudos