Appian Locust Library documentation

What is Appian Locust?

Appian Locust is a wrapper library around Locust for load testing Appian. This library is intended to be used as an alternative to tools such as Jmeter and Load Runner.

Appian Locust capabilities

  • Logging in and logging out

  • Form interactions (filling/submitting)

  • Finding and interacting with basic components on a SAIL interface

  • Navigating to records/reports/sites

What is Locust?

It’s an open source python library for doing load testing (think JMeter, but in Python). It is by default HTTP-driven, but can be made to work with other types of interactions. Visit Locust for more information.

Locust has the benefit of relying purely on API requests, which makes it lower overhead than frameworks building on Selenium or browser automation libraries. We have also found python to be common denominator across software and quality engineers, making it a convenient language for extending the framework and defining tests. Using Locust’s model of TaskSets and TaskSequences, it is easy to compose user operations in a maintainable way. Appian-Locust builds on these concepts by defining AppianTaskSet and AppianTaskSequence, which layer on Appian-specific functionality such as login and session management.

SAIL Navigation

Appian interfaces are built with SAIL. It’s a RESTful contract that controls state between the browser/mobile clients and the server.

All SAIL-based interactions require updating a server-side context (or in a stateless mode, passing that context back and forth). These updates are expressed as JSON requests sent back and forth, which are sent as “SaveRequests”, usually to the same endpoint from which the original SAIL form was served. Each SaveRequest, if successful, will return an updated component, or a completely new form if a modal is opened or a button is clicked on a wizard.

Appian Locust abstracts away the specifics of these requests into methods that make it easy to quickly create new workflows for Locust’s virtual users to execute on an Appian instance, For more details on how Appian Locust enables this, check out the How to Write a Locust Test section.

Quick Installation Guide

This is a quick guide to getting up and running with the appian-locust library. You will need Python 3.10 installed on your machine before proceeding.

Setup

  1. Install appian-locust using pip, for more comprehensive projects we recommend using pipenv.

pip install appian-locust

If using pipenv, simply start from the following Pipfile:

[packages]
appian-locust = {version = "*"}

[requires]
python_version = "3.10"

[pipenv]
allow_prereleases = true
  1. Download the sample test example_locustfile.py from the Appian Locust repo and run it.

locust -f example_locustfile.py

If everything is set up correctly, you should see a link to the Locust web interface, which you can use to start test runs and view results.

Build from source

Clone the repository:

git clone -o prod git@gitlab.com:appian-oss/appian-locust.git

Install the library globally:

pip install -e appian-locust

If you’re using a virtualenv or a dependency management tool (e.g. pipenv), you can do the same type of install, but you will want to be in the context of the virtualenv (i.e. source the virtualenv), and you’ll need to pass the path to the repository you cloned.

Note: It’s highly recommended that you use a virtual environment when installing python artifacts. You can follow the instructions here to install virtualenv and pip.

If you have issues installing, make sure you have the proper prerequisites installed for Locust and its dependencies. If you’re having trouble on Windows, check here

Troubleshooting

  • Do not have permissions to clone appian-locust

    • Ensure you have added you ssh key to your profile. See here for how to do this.

  • “locust is not available”

    • Verify that you ran pip install -e appian-locust

  • “Failed to establish a new connection: [Errno 8] nodename nor servname provided, or not known”

    • check that host_address is specified correctly in your locust test file.

  • “Login unsuccessful, no multipart cookie found…make sure credentials are correct”

    • check that auth specifies a valid username and password combination for the site you’re testing on in your locust test file.

  • “General request and response debugging”

    • Add self.client.record_mode = True to your HttpUser subclass. Files will be placed in /record_responses where the runner is executed.

How to Write a Locust Test

The majority of the work involved in writing Appian Locust tests is around creating Locust Tasks. Each Task represents a workflow for a virtual Locust user to execute. This section will go over how to get started writing tasks and introduce the two core Appian Locust concepts: the Visitor class and the SailUiForm class.

Sample Workflow

For this workflow, we will use the Employee Record Type found in the Appian documentation. We will implement a Locust Task that will create a new Employee record. The specific workflow will be as follows:

  1. Navigate to the Employee Record List

  2. Click the “New Employee” button

  3. Fill in the First Name, Last Name, Department, Title, and Phone Number fields

  4. Click the “Create” button

Appian Navigation - Visitor

The first step in most workflows is to navigate our user to an interface to interact with. All navigation in Appian Locust can be accomplished via the Visitor. Any kind of Appian interface, including Sites, Records, Reports, Portals and others can be navigated to via the Visitor, and the Visitor will return a SailUiForm which can be used to interact with that interface.

In our case, we want to navigate to a Record Type list. In Appian Locust, that would look like the following:

@task
def create_new_employee(self):
    # Navigate to Employee Record List
    record_list_uiform = self.appian.visitor.visit_record_type(record_type="Employees")

UI Interactions - SailUiForm

Now that we have navigated to the Employee Record List, we need to execute the workflow steps that will create the new Employee. As briefly touched on above, all navigations done via the Visitor return a SailUiForm which is capable of performing interactions with a UI. SailUiForm supports filling in text fields, clicking buttons and more.

In our specific case, because we navigated to a Record list, our visitor returned a subclass of the SailUiForm: the RecordListUiForm. Some types of interfaces in Appian have a specific subclass that will support additional functionality catered to the kind of interface it represents. The RecordListUiForm will enable us to click on the Record List Action which is unique to Record Lists via click_record_list_action, like so:

@task
def create_new_employee(self):
    # Navigate to Employee Record List
    record_list_uiform = self.appian.visitor.visit_record_type(record_type="Employees")

    # Click on "New Employee" Record List Action
    record_list_uiform.click_record_list_action(label="New Employee")

As shown above, in many cases the only thing required to interact with a UI element is the label associated with that element.

At this point in the workflow, the dialog to create a new employee has been launched. Now we can use the various other interactions supported by the base SailUiForm class which are available on all of its subclasses to fill out the new Employee’s information:

@task
def create_new_employee(self):
    # Navigate to Employee Record List
    record_list_uiform = self.appian.visitor.visit_record_type(record_type="Employees")

    # Click on "New Employee" Record List Action
    record_list_uiform.click_record_list_action(label="New Employee")

    # Fill in new Employee information
    record_list_uiform.fill_text_field(label="First Name", value="Sample")
    record_list_uiform.fill_text_field(label="Last Name", value="User")
    record_list_uiform.fill_text_field(label="Department", value="Engineering")
    record_list_uiform.fill_text_field(label="Title", value="Senior Software Engineer")
    record_list_uiform.fill_text_field(label="Phone Number", value="(703) 442-8844")

Now all we need to do is click the “Create” button, and our new Employee will be created!

@task
def create_new_employee(self):
    # Navigate to Employee Record List
    record_list_uiform = self.appian.visitor.visit_record_type(record_type="Employees")

    # Click on "New Employee" Record List Action
    record_list_uiform.click_record_list_action(label="New Employee")

    # Fill in new Employee information
    record_list_uiform.fill_text_field(label="First Name", value="Sample")
    record_list_uiform.fill_text_field(label="Last Name", value="User")
    record_list_uiform.fill_text_field(label="Department", value="Engineering")
    record_list_uiform.fill_text_field(label="Title", value="Senior Software Engineer")
    record_list_uiform.fill_text_field(label="Phone Number", value="(703) 442-8844")

    # Create Employee!
    record_list_uiform.click_button(label="Create")

If you run a locust test with the task above, you should be able to check the Employee record list and see the “Sample User” employees that the virtual Locust user just made! You can see a full version of a locust test including the task we just wrote here.

How to Run Locust

Once you have the library installed, you can simply follow the output from running locust -h to run your test. Note that if you don’t specify a locustfile with -f FILE_NAME, locust will look for a file called locustfile in the current directory by default.

Command Line Flow

If you’re running Locust somewhere where there is no web ui, or you don’t want to bother with the web flow, I tend to run Locust like so:

locust -f examples/example_locustfile.py -u 1 -r 10 -t 3600 --headless

Required Arguments

This is the bare minimum to run a Locust test without the web view. You’ll notice you need to specify:

  • The hatch rate (-r is for rate)

  • The number of users (-u is for users)

  • The time of the test (-t) in seconds

  • The –headless flag

Once you run this command, the test will start immediately, and start logging output. It should run for the duration you run the test, and hitting ctrl+c may orphan some locusts. Some arguments that we use are

  • –csv-full-history -> prints out the different percentile changes every 30 seconds

It’s recommended to capture log file output when running Locust as well, i.e. | tee run.log

Web Flow

If you supply no arguments to locust other than the locustfile, Locust will launch in a web mode. This is not recommended, mainly because you can’t automate running it as it requires manual interaction as well as access to the flask application.

locust -f example_locustfile.py

If you navigate to http://localhost:8089/ you’ll see the following:

Locust web view

These arguments map to the same arguments described in the Required Arguments section.

Once you hit “start swarming”, you’ll see graphs reporting latencies, errors, and other data. These can be useful for visually understanding how a load test is performing.

Debugging

Oftentimes you will encounter errors or not get the appropriate load you expect when running tests.

Things you can use to get more information:

  1. Add print statements to your Locust code or the installed appian-locust library

  2. Inspect the output of the latencies that Locust periodically prints out, to see if certain requests are much slower than you expect

  3. Verify using the browser console that the requests you are attempting to simulate match up with what Locust/appian-locust is sending

  4. Setting the “record_mode” attribute to True on your HttpUser’s client object will create a “recorded_responses” folder which will contain all requests and responses sent during test execution. You can do this in the __init__ method of your HttpUser, like so:

def __init__(self, environment) -> None:
    super().__init__(environment)
    self.client.record_mode = True

Limitations

Disclaimer: This library is continuously evolving. Currently the main focus is supporting essential use-cases. We are happy to accept contributions to further extend functionality, address bug fixes and improve usability. Please see the Contributing section and feel free to reach out.

Currently unsupported Appian Interactions

  • Multiple links with the same name on the same page, use labels to differentiate

  • Legacy forms

Limitations when running Locust

  • On Windows, running locust in distributed mode is not supported

Advanced Appian Locust Usage

Loading Test Settings from Config

These three lines look for a config.json file at the location from which the script is run (not where the locustfile is).

from appian_locust.utilities import loadDriverUtils

utls = loadDriverUtils()
utls.load_config()

This takes the content of the config.json file and places it into a variable as utls.c. This allows us to access configurations required for logging in inside the class that extends HttpUser:

config = utls.c
auth = config['auth']
host = "https://" + config['host_address']

A minimal config.json looks like:

{
    "host_address": "site-name.appiancloud.com",
    "auth": [
        "user.name",
        "password"
    ]
}

Advanced Test Examples

Locust Test Example: Records

An example of a Locust Test showing interaction with Appian Records - example_locust_test_records.py.

This test has 2 locust tasks defined and it will execute all three for each spawned locust user for the duration of the test.

  • Task 1 will visit a random record instance for a random record type and return the SAIL form.

  • Task 2 will visit a random record type list and get the SAIL form for it, then filter the records in the list.

@task takes an optional weight argument that can be used to specify the task’s execution ratio. For example: If there are two tasks with weights 3 and 6 then second task will have twice the chance of being picked as first task.

# Locust tests executing against Appian with a TaskSet should set AppianTaskSet as their base class to have access to various functionality.
# This class handles creation of basic objects like self.appian (appian client) and actions like `login` and `logout`

class RecordsTaskSet(AppianTaskSet):

    def on_start(self):
        super().on_start()
        # could be either view or list:
        if "endpoint_type" not in CONFIG:    # could be either view or list:
            logger.error("endpoint_type not found in config.json")
            sys.exit(1)

        self.endpoint_type = CONFIG["endpoint_type"]

        # view - to view records from opaqueId
        # list - to list all the record types from url_stub
        if "view" not in self.endpoint_type and "list" not in self.endpoint_type:
            logger.error(
                "This behavior is not defined for the provided endpoint type. Supported types are : view and list")
            sys.exit(1)

    def on_stop(self):
        logger.info("logging out")
        super().on_stop()

    @task(X)
    def visit_random_record(self):
        if "view" in self.endpoint_type:
            self.appian.visitor.visit_record_instance()

    @task(X)
    # this task visits a random record type list and return the SAIL form.
    def visit_random_record_type_list(self):
        if "list" in self.endpoint_type:
            record_list = self.appian.records.visit_record_type_and_get_form()
            record_list.filter_records_using_searchbox("Favorite Record Name")
  • By calling super().on_start() inside the on_start() function of the locust test you get access to the appian client which allows you to call self.appian.visitor, self.appian.system_operator etc. These properties allow us to navigate to a specific object or access metadata about available objects.

  • Functions like visit_XYZ() access the actual appian object and return its SAIL form as an instance of uiform. This class contains methods that helps you interact with the UI.

Locust Test Example: Grids

An example of a Locust Test showing interaction with Appian Grids - example_locust_test_grids.py.

This test has a locust task defined that will interact with a read-only paging grid layout that contains an Employee directory. Configuration for this grid layout is inspired by Appian’s Grid Tutorial. The goal of this test is to select all Engineering employees and view the details for one of them.

The first step in this workflow is to navigate our user to a report which is backed by an interface containing the grid:

@task
def interact_with_grid_in_interface(self):
    # Navigate to the interface backed report that contains a grid
    report_uiform = self.appian.visitor.visit_report(report_name="Employee Report with Grid")

The interface will look similar to this:

Report with a grid

Now that we have navigated to the report, we will sort the grid by the Department field in the ascending order to have all Engineering department employees at the top:

@task
def interact_with_grid_in_interface(self):
    # Navigate to the interface backed report that contains a grid
    report_uiform = self.appian.visitor.visit_report(report_name="Employee Report with Grid")

    # Sort the grid rows by the "Department" field name
    report_uiform.sort_paging_grid(label="Employee Directory", field_name="Department", ascending=True)

The interface with the sorted grid will look similar to this:

Grid sorted by Department field

Next, we will select the first five rows on the first page of the grid:

@task
def interact_with_grid_in_interface(self):
    # Navigate to the interface backed report that contains a grid
    report_uiform = self.appian.visitor.visit_report(report_name="Employee Report with Grid")

    # Sort the grid rows by the "Department" field name
    report_uiform.sort_paging_grid(label="Employee Directory", field_name="Department", ascending=True)

    # Select the first five rows on the first page of the grid
    report_uiform.select_rows_in_grid(rows=[0,1,2,3,4], label="Employee Directory")

Because the grid is configured to show the selected rows under Selected Employees, the resultant interface will look similar to this:

Selected rows in first page of grid

During development, this would be a good way to test the selection using the JSON response from the above request. Next, we will move to the second page of the grid and select the first row since it also contains an Engineering employee:

@task
def interact_with_grid_in_interface(self):
    # Navigate to the interface backed report that contains a grid
    report_uiform = self.appian.visitor.visit_report(report_name="Employee Report with Grid")

    # Sort the grid rows by the "Department" field name
    report_uiform.sort_paging_grid(label="Employee Directory", field_name="Department", ascending=True)

    # Select the first five on the first page of the grid
    report_uiform.select_rows_in_grid(rows=[0,1,2,3,4], label="Employee Directory")

    # Move to the second page of the grid
    report_uiform.move_to_right_in_paging_grid(label="Employee Directory")

    # Select the first row on the second page of the grid
    report_uiform.select_rows_in_grid(rows=[0], label="Employee Directory", append_to_existing_selected=True)

The interface will look similar to this:

Selected first row in second page of grid

The grid contains a First Name column which is a link to the employee record. Finally, we will click on the link for an employee William:

@task
def interact_with_grid_in_interface(self):
    # Navigate to the interface backed report that contains a grid
    report_uiform = self.appian.visitor.visit_report(report_name="Employee Report with Grid")

    # Sort the grid rows by the "Department" field name
    report_uiform.sort_paging_grid(label="Employee Directory", field_name="Department", ascending=True)

    # Select the first five on the first page of the grid
    report_uiform.select_rows_in_grid(rows=[0,1,2,3,4], label="Employee Directory")

    # Move to the second page of the grid
    report_uiform.move_to_right_in_paging_grid(label="Employee Directory")

    # Select the first row on the second page of the grid
    report_uiform.select_rows_in_grid(rows=[0], label="Employee Directory", append_to_existing_selected=True)

    # Click on the row with a record link with the given label
    report_uiform.click_record_link(label="William")

The user will be navigated to the employee’s record which will look similar to this:

Record view for William Ross

You can see a full version of this locust test here. There are other useful functions for interacting with grids that can be found in our documentation.

Locust Test Example: Multiple Users

An example of a Locust Test showing interaction with the frontend and admin pages - example_multi_user_locustfile.py.

This test has 2 locust TaskSets defined and 2 HttpUsers defined that simulate different login information.

TaskSets

  • The GetFrontPageTaskSet simply navigates to a basic user landing page

  • The GetAdminPageTaskSet navigates to the admin console

HttpUsers

  • The FrontendUserActor uses the login credentials for the regular frontend user

  • The AdminUserActor uses the login credentials for the admin user

Running

When running this locustfile, it is important to note that the users specified when running are spread evenly across the HttpUsers. If you only specify one user to run when running locust, it will only choose one user actor to start:

locust -f examples/example_multi_user_locustfile.py --headless -r 10 -t 3600 --users 1
...
All users hatched: FrontendUserActor: 1, AdminUserActor: 0 (0 already running)

Make sure to run the locustfile with at least as many users as required by how you have the weights configured for each HttpUser.

For the included sample, the weights are 3 and 1 respectively, meaning you’ll have to spawn 4 users to get the AdminUserActor to start up

locust -f examples/example_multi_user_locustfile.py  --headless -r 10 -t 3600 --users 4
...
All users hatched: FrontendUserActor: 3, AdminUserActor: 1 (0 already running)

Advanced Task Execution

Executing a specific number of tasks

One can also write a test that executes a set number of iterations of your TaskSequence Class and all its tasks, instead of executing the test for X number of seconds/mins/hours. Here’s a snippet showing how to run a test for a set number of iterations.

import json
import os
from locust import HttpUser, task, between, events

from appian_locust import AppianTaskSet

@events.init.add_listener
def on_locust_init(environment, **kw):
    global ENV
    ENV = environment


class OrderedEndToEndTaskSequence(AppianTaskSequence):
    @task
    def nav_to_random_site(self):
        pass

    @task
    def nav_to_specific_site(self):
        pass

    @task
    def increment_iteration_counter(self):
        logger.info(f"Iteration Number: {self.iterations}")
        # Stop the test if 40 iterations of the set have been completed.
        # This would mean approximately 40K requests in total for the test.
        if self.iterations >= CONFIG["num_of_iterations"]:
            logger.info(f"Stopping the Locust runner")
            ENV.runner.quit()
        else:
            logger.info(f"Incrementing the iteration set counter")
            self.iterations += 1

class UserActor(HttpUser):
    tasks = [GetFrontPageTaskSet]
    config_file = "./example_config.json"
    CONFIG = {}
    if os.path.exists(config_file):
        with open(config_file, 'r') as config_file:
            CONFIG = json.load(config_file)
    else:
        raise Exception("No config.json found")
    host = f'https://{CONFIG["host_address"]}'
    auth = CONFIG["auth"]
    wait_time = between(0.500, 0.500)

Note: CONFIG["num_of_iterations"] is retrieved from the test configuration. This should be provided in the example_config.json file.

The way to achieve specific number of tasks in this test is by having a counter in your task, that you increment once in a specific Locust task and then stop the test when you have reached the desired number of iterations.

Waiting until all users are spawned

If you want to wait for your test to spawn all of the Locust users

from gevent.lock import Semaphore

all_locusts_spawned = Semaphore()
all_locusts_spawned.acquire()

@events.spawning_complete.add_listener
def on_spawn_complete(**kw):
    print("All news users can start now!")
    all_locusts_spawned.release()

class WaitingTaskSet(AppianTaskSet):

    def on_start(self):
        """ Executes before any tasks begin."""
        super().on_start()
        all_locusts_spawned.wait()

Latest Release

Version 2.0.0

Appian Locust v2.0 introduces a significant rework of the API to guide a clear and streamlined development experience. Our target was to meet feature parity while simplifying the steps to interact with Appian.

New Paradigm

  • Visitor is the new hub for all SailUiForm navigations. From the client, you can call various methods to retrieve the desired SailUiForm that matches the type of page that the caller has navigated to, which will enable further interaction with the represented UI.

  • SystemOperator is for non UI form interactions at the system level (i.e. get_webapi()).

  • The info module extended from AppianClient provides metadata for News, Tasks, Records, Reports, Actions, and Sites (i.e. appian_locust.appian_client.AppianClient.actions_info).

Breaking Changes

  • Appian Locust now requires Python 3.10. Update your dependencies globally or within your dependency management config file.

  • Fetching SailUIForms from News, Tasks, Records, Reports, Actions, and Sites have been marked as private. Use Visitor to handle all UI navigations.

  • SailUIForms types can be found in the uiform module.

  • Design objects and types have been moved to the objects module.

  • Various helper methods have been moved to the utilities module.

  • loadDriverUtils() does not provide utls anymore. Instead, call loadDriverUtils() to set utls:

    from appian_locust.utilities import loadDriverUtils
    utls = loadDriverUtils()
    

For a more comprehensive list of changes in Appian Locust 2.0, see the Appian Locust 2.0 Migration Guide document.

Appian Locust 2.0 Migration Guide

_actions.py

The _Actions class is no longer available. You may migrate any functionality using this class as follows:

Method in 1.x

Method in 2.x

Example Usage

get_actions_interface

Not available anymore. Handled internally by the framework whenever it is necessary, so any calls to this can be removed without replacement.

get_actions_feed

Not available anymore. Handled internally by the framework whenever it is necessary, so any calls to this can be removed without replacement.

get_all

We can do the same operation from the actions_info.py method named “get_all_available_actions”.

all_available_actions = self.appian.actions_info.get_all_available_actions(..)

get_action

We can do the same operation from the actions_info.py method named “get_action_info”.

specific_action_info = self.appian.actions_info.get_action_info(..)

visit_and_get_form

We can perform the same operation by the “visit_action” method from visitor.py.

uiform = self.appian.visitor.visit_action(..)

visit

Not available. Call the “visit_action” method from visitor.py instead.

uiform = self.appian.visitor.visit_action(..)

start_action

We can perform the same operation by calling “start_action” in system_operator.py

response = self.system_operator.start_action(...)

_admin.py

The _Admin class is no longer available. You may migrate any functionality using this class as follows:

Method in 1.x

Method in 2.x

Example Usage

visit

We can do the same operation by calling “visit_admin” method in visitor.py

uiform = self.appian.visitor.visit_admin()

_design.py

The _Design class is no longer available. You may migrate any functionality using this class as follows:

Method in 1.x

Method in 2.x

Example Usage

visit

We can do the same operation by calling “visit_design” method in visitor.py

uiform = self.appian.visitor.visit_design()

visit_object

We can do the same operation by calling “visit_design_object_by_id” method in visitor.py

design_object_uiform = self.appian.visitor.visit_design_object_by_id(..)

visit_app

We can do the same operation by calling “visit_application_by_id” method in visitor.py

application_uiform = self.appian.visitor.visit_application_by_id(..)

create_application

We can do the same operation by calling “create_application” method in design_uiform.py

design_uiform = self.appian.visit_design()
application_uiform = design_uiform.create_application(..)

create_record_type

We can do the same operation by calling “create_record_type” method in application_uiform.py

application_uiform = self.appian.visitor.visit_application_by_id(..)
application_uiform = application_uiform.create_record_type(..)

create_report

We can do the same operation by calling “create_recport” method in application_uiform.py

application_uiform = self.appian.visitor.visit_application_by_id(..)
application_uiform = application_uiform.create_report(..)

_news.py

The _News class is no longer available. You may migrate any functionality using this class as follows:

Method in 1.x

Method in 2.x

Example Usage

get_all

We can do the same operation by calling the “get_all_available_entries” method in news_info.py.

news_dict = self.appian.news_info.get_all_available_entries(..)

get_news

We can do the same operation by calling the “get_news_entry” method in news_info.py.

specific_news_info = self.appian.news_info.get_news_entry(..)

visit

Not available. Call “get_news_entry” method in news_info.py instead.

specific_news_info = self.appian.news_info.get_news_entry(..)

visit_news_entry

Not available. Call “get_news_entry” method in news_info.py instead.

specific_news_info = self.appian.news_info.get_news_entry(..)

search

We can do the same operation by calling the “get_all_available_entries” with a search string argument.

uiform = self.appian.news_info.get_all_available_entries(..)

_records.py

The _Records class is no longer available. You may migrate any functionality using this class as follows:

Method in 1.x

Method in 2.x

Example Usage

visit_record_instance_and_get_feed_form

Not available. Call “visit_record_instance” method in visitor.py instead.

record_instance_uiform = self.appian.visitor.visit_record_instance(..)

visit_record_instance_and_get_form

We can do the same operation by calling the “visit_record_instance” method in visitor.py

record_instance_uiform = self.appian.visitor.visit_record_instance(..)

visit_record_type_and_get_form

We can do the same operation by calling the “visit_record_type” method in visitor.py

record_list_uiform = self.appian.visitor.visit_record_type(..)

get_all

We can do the same operation from the record_list_uiform.py method named “get_visible_record_instances”.

record_list_uiform = self.appian.visitor.visit_record_type(..)
all_records_info = record_list_uiform.get_visible_record_instances(..)

get_all_record_types

“get_all_available_record_types” in records_info.py

records_dict = self.appian.records_info.get_all_available_record_types(..)

get_all_records_of_record_type

“get_visible_record_instances” in record_list_uiform.py

record_list_uiform = self.appian.visitor.visit_record_type(..)
all_records_info = record_list_uiform.get_visible_record_instances(..)

get_records_interface

Not available. Handled internally by the framework whenever it is necessary, so any calls to this can be removed without replacement.

get_records_nav

Not available. Handled internally by the framework whenever it is necessary, so any calls to this can be removed without replacement.

get_all_records_of_record_type_mobile

To interact with an Appian instance as a mobile client, pass in “is_mobile_client=True” in the AppianTaskSet on_start method.

class SampleTaskSet(AppianTaskSet)
def on_start(self):
super().on_start(is_mobile_client=True)

get_all_mobile

To interact with an Appian instance as a mobile client, pass in “is_mobile_client=True” in the AppianTaskSet on_start method.

class SampleTaskSet(AppianTaskSet)
def on_start(self):
super().on_start(is_mobile_client=True)

fetch_record_instance

Not available. Instead call “visit_record_instance” method in visitor.py

record_instance_uiform = self.appian.visitor.visit_record_instance(..)

fetch_record_type

Not available. Instead call “visit_record_type” method in visitor.py

record_list_uiform = self.appian.visitor.visit_record_type(..)

visit_record_instance

Not available. Instead call “visit_record_instance” method in visitor.py

record_instance_uiform = self.appian.visitor.visit_record_instance(..)

visit_record_type

Not available. Instead call “visit_record_type” method in visitor.py

record_list_uiform = self.appian.visitor.visit_record_type(..)

_reports.py

The _Reports class is no longer available. You may migrate any functionality using this class as follows:

Method in 1.x

Method in 2.x

Example Usage

get_reports_interface

Not available anymore. Handled internally by the framework whenever it is necessary, so any calls to this can be removed without replacement.

get_reports_nav

Not available anymore. Handled internally by the framework whenever it is necessary, so any calls to this can be removed without replacement.

get_all

We can do the same operation by calling the “get_all_available_reports” in reports_info.py

reports_dict = self.appian.reports_info.get_all_available_reports(..)

get_report

We can do the same operation by calling the “get_report_info” in reports_info.py

report_info = self.appian.reports_info.get_report_info(..)

visit_and_get_form

We can do the same operation by calling the “visit_report” in visitor.py

uiform = self.appian.visitor.visit_report(..)

visit

Not available. Instead call “visit_report” in visitor.py

uiform = self.appian.visitor.visit_report(..)

_tasks.py

The _Tasks class is no longer available. You may migrate any functionality using this class as follows:

Method in 1.x

Method in 2.x

Example Usage

get_all

We can do the same operation by calling “get_all_available_tasks” method in tasks_info.py

tasks_dict = self.appian.tasks_info.get_all_available_tasks(..)

get_task_pages

Not available. Handled internally by the framework whenever it is necessary, so any calls to this can be removed without replacement.

get_next_task_page_uri

Not available. Handled internally by the framework whenever it is necessary, so any calls to this can be removed without replacement.

visit_and_get_form

We can do the same operation by calling “visit_task” method in visitor.py

uiform = self.appian.visitor.visit_task(..)

visit

Not available. Instead call “visit_task” in visitor.py

uiform = self.appian.visitor.visit_task(..)

_sites.py

The _Sites class is no longer available. You may migrate any functionality using this class as follows:

Method in 1.x

Method in 2.x

Example Usage

navigate_to_tab

Not available. Instead call “visit_site” in visitor.py

uiform = self.appian.visitor.visit_site(..)

navigate_to_tab_and_record_if_applicable

Not available. Instead call “visit_site_recordlist_and_get_random_record_form” in visitor.py

record_instance_uiform = self.appian.visitor.visit_site_recordlist_and_get_random_record_form(..)

navigate_to_tab_and_record_get_form

We can do the same operation by calling “visit_site_recordlist_and_get_random_record_form” in visitor.py

record_instance_uiform = self.appian.visitor.visit_site_recordlist_and_get_random_record_form(..)

get_all

We can do the same operation by calling “get_all_available_sites” in sites_info.py

sites_dict = self.appian.sites_info.get_all_available_sites()

get_site_data_by_site_name

We can do the same operation by calling “get_site_info” in sites_info.py

specific_site = self.appian.sites_info.get_site_info(..)

get_page_names_from_ui

Not available. Instead call “get_site_info” in sites_info.py

specific_site = self.appian.sites_info.get_site_info(..)

get_site_page

Not available. You can get page information from the Site object returned by “get_site_info” in sites_info.py

specific_site = self.appian.sites_info.get_site_info(..)

visit_and_get_form

We can do the same operation by calling “visit_site” in visitor.py

uiform = self.appian.visitor.visit_site(..)

_app_importer.py

The _app_importer module is no longer available. You may migrate any functionality using this module as follows:

Method in 1.x

Method in 2.x

Example Usage

import_app

Available on DesignUiForm.py as “import_application”

design_uiform = self.appian.visitor.visit_design()
design_uiform.import_application(..)

uiform.py

The following methods in SailUiForm have removed or modified:

Method in 1.x

Method in 2.x

Example Usage

get_record_header_form

Available on RecordInstanceUiForm.py as “get_header_view”

record_form = self.appian.visitor.visit_record_instance(“record_type”, “record_name”)
record_header_form = record_form.get_header_view()

get_record_view_form

Available on RecordInstanceUiForm.py as “get_summary_view”

record_form = self.appian.visitor.visit_record_instance(“record_type”, “record_name”)
record_header_form = record_form.get_summary_view()

get_response

Not available. Instead use “get_latest_state”

ui_form.get_latest_state()

latest_state

Not available. Instead use “get_latest_state”

ui_form.get_latest_state()

get_latest_form

Not available. This method basically just returned “this”, so it was unnecessary.

click_record_link

This method now returns a new type, a RecordInstanceUiForm, so you must make sure to save the return value into a new variable

record_uiform: RecordInstanceUiform = uiform.click_record_link(..)

_records_helper.py

This class and all its methods are not accessible anymore.

_interactor.py

This class and all its methods are not accessible anymore. There are corresponding methods in other new classes/existing classes.

Import Changes

Class/Module

Import in V1

Import in V2

helper

from appian_locust.helper import *

from appian_locust.utilities.helper import *

Site

from appian_locust._sites import Site

from appian_locust.objects import Site

Page

from appian_locust._sites import Page

from appian_locust.objects import Page

PageType

from appian_locust._sites import PageType

from appian_locust.objects import PageType

utls

from appian_locust.loadDriverUtils import utls

from appian_locust.utilities import loadDriverUtils
utls = loadDriverUtils()

API

class appian_locust.feature_flag.FeatureFlag(value)

Bases: Enum

An enumeration.

ALL_FEATURES = 1
ALWAYS_ADD_RECORD_TYPE_INFORMATION = 45
BILLBOARD_LAYOUT = 35
BODY_URI_TEMPLATES = 14
BOX_LAYOUT = 20
CARD_LAYOUT = 39
CERTIFIED_SAIL_EXTENSION = 47
COMPACT_URI_TEMPLATES = 10
DATA_EXPORT = 23
DOCUMENT_VIEWER_LAYOUT = 44
EVOLVED_BILLBOARD_LAYOUT = 50
EVOLVED_GRIDFIELD = 52
FILTERS_LAYOUT = 48
FLUSH_RECORD_HEADERS = 57
GAUGE_FIELD = 55
GRID_ROW_SELECTION = 40
ICON_WIDGET = 31
IMAGES_INTERFACES_2 = 27
IMAGE_CROPPING = 21
IMPLICIT_SYSTYPE_NAMESPACE = 5
INLINE_TASK_CONTROLS = 12
IN_APP_BROWSER_AUTH = 59
JUSTIFIED_LABEL_POSITION = 22
LESS_OPAQUE_BILLBOARD_OVERLAYS = 58
MEDIUM_LARGE_RICH_TEXT = 19
MODERN_RECORD_TYPES_LIST = 38
MULTIPLE_SAVE_USER_FILTERS = 60
MULTI_SELECT_RECORD_FILTERS = 17
NESTED_COLUMNS = 16
NEWS_ENTRY_LAYOUT = 43
NEWS_SUBSCRIPTION_SETTINGS = 46
NEW_COLUMN_WIDTHS = 53
NEW_RICH_TEXT_SIZES = 54
NO_FEATURES = 0
OFFLINE = 6
PARTIAL_RENDERING = 7
POSITIVE_NEGATIVE_RICH_TEXT = 18
REACT_CLIENT = 11
RECORD_ACTION_COMPONENT = 61
RECORD_CHROME = 34
RECORD_LIST_FEED_ITEM_DTO = 36
RECORD_NEWS = 4
RECORD_NEWS_FIELD = 37
RELATIVE_URI_TEMPLATES = 13
REST_REDIRECT = 9
RICH_TEXT_ACCENT_STYLE = 32
SAIL_FORMS = 2
SHORT_CIRCUIT_PARTIAL_RENDERING = 8
SIDE_BY_SIDE_LAYOUT = 28
SITE_RECORD_NEWS = 24
SUBMISSION_LOCATION = 51
TAG_FIELD = 56
TASK_FORM_LAYOUT = 33
TASK_PREVIEW = 3
TWO_PART_RECORD_TAG_URI = 15
USE_CLIENT_LOCALE = 41
USE_MULTIPART_RECORD_UIS = 42
WCC_READ_ONLY = 29
WCC_READ_WRITE = 30

Exceptions module

exception appian_locust.exceptions.exceptions.BadCredentialsException

Bases: Exception

exception appian_locust.exceptions.exceptions.ChoiceNotFoundException

Bases: Exception

exception appian_locust.exceptions.exceptions.ComponentNotFoundException

Bases: Exception

exception appian_locust.exceptions.exceptions.IncorrectDesignAccessException(object_type: str, correct_access_method: str)

Bases: Exception

exception appian_locust.exceptions.exceptions.InvalidComponentException

Bases: Exception

exception appian_locust.exceptions.exceptions.InvalidDateRangeException(start_date: date, end_date: date)

Bases: Exception

exception appian_locust.exceptions.exceptions.InvalidSiteException

Bases: Exception

exception appian_locust.exceptions.exceptions.MissingConfigurationException(missing_keys: list)

Bases: Exception

exception appian_locust.exceptions.exceptions.MissingCsrfTokenException(found_cookies: dict)

Bases: Exception

exception appian_locust.exceptions.exceptions.MissingUrlProviderException

Bases: Exception

exception appian_locust.exceptions.exceptions.PageNotFoundException

Bases: Exception

exception appian_locust.exceptions.exceptions.SiteNotFoundException

Bases: Exception

Info module

Objects module

class appian_locust.objects.ai_skill.AiSkill(host_url: str, object_uuid: str)

Bases: object

class appian_locust.objects.ai_skill_type.AISkillObjectType(value)

Bases: Enum

An enumeration.

DOCUMENT_CLASSIFICATION = 2
DOCUMENT_EXTRACTION = 5
EMAIL_CLASSIFICATION = 8
class appian_locust.objects.application.Application(name: str, opaque_id: str)

Bases: object

Class representing an application

class appian_locust.objects.design_object.DesignObject(name: str, opaque_id: str)

Bases: object

Class representing an Design Object

class appian_locust.objects.design_object.DesignObjectType(value)

Bases: Enum

An enumeration.

DATA_TYPE = 'Data Type'
DECISION = 'Decision'
EXPRESSION_RULE = 'Expression Rule'
INTEGRATION = 'Integration'
INTERFACE = 'Interface'
RECORD_TYPE = 'Record Type'
SITE = 'Site'
TRANSLATION_SET = 'Translation Set'
WEB_API = 'Web API'
class appian_locust.objects.page.Page(page_name: str, page_type: PageType, site_stub: str, group_name: str | None = None)

Bases: object

Class representing a single Page within a site

class appian_locust.objects.page.PageType(value)

Bases: Enum

An enumeration.

ACTION: str = 'action'
INTERFACE: str = 'interface'
RECORD: str = 'recordType'
REPORT: str = 'report'
class appian_locust.objects.site.Site(name: str, display_name: str, pages: Dict[str, Page])

Bases: object

Class representing a single site, as well as its pages

Uiform module

Utilities module

appian_locust.utilities.credentials.procedurally_generate_credentials(CONFIG: dict) None

Helper method that can be used to procedurally generate a set of Appian user credentials

Note: This class must be called in the UserActor class of your Locust test in order to create the credentials before any Locust users begin to pick them up.

Parameters:
  • CONFIG – full locust config dictionary, AKA the utls.c variable in locust tests Make sure the following keys are present.

  • procedural_credentials_prefix – Base string for each generated username

  • procedural_credentials_count – Appended to prefix, will create 1 -> Count+1 users

  • procedural_credentials_password – String which will serve as the password for all users

Returns:

None

appian_locust.utilities.credentials.setup_distributed_creds(CONFIG: dict) dict

Helper method to distribute Appian credentials across separate load drivers when running Locust in distributed mode. Credential pairs will be passed out in Round Robin fashion to each load driver.

Note: This class must be called in the UserActor class of your Locust test to ensure that the “credentials” key is prepared before tests begin.

Note: If fewer credential pairs are provided than workers, credentials will be distributed to workers in a Modulo fashion.

Parameters:

CONFIG – full locust config dictionary, AKA the utls.c variable in locust tests Make sure the following keys are present.

Returns:

same as input but with credentials key updated to just the subset of credentials required for given load driver.

Return type:

CONFIG

appian_locust.utilities.helper.extract_all_by_label(obj: dict | list, label: str) list

Recursively search for all fields with a matching label in JSON tree. :param obj: The json tree to search for fields in :param label: The label used to identify elements we want to return

Returns (list): A list of all elements in obj that match label

appian_locust.utilities.helper.extract_values(obj: Dict[str, Any], key: str, val: Any) List[Dict[str, Any]]

Pull all values of specified key from nested JSON.

Parameters:
  • obj (dict) – Dictionary to be searched

  • key (str) – tuple of key and value.

  • val (any) – value, which can be any type

Returns:

list of matched key-value pairs

appian_locust.utilities.helper.extract_values_multiple_key_values(obj: Dict[str, Any], key: str, vals: List[Any]) List[Dict[str, Any]]

Pull all values where the key value matches an entry in vals from nested JSON.

Parameters:
  • obj (dict) – Dictionary to be searched

  • key (str) – a key in the dictionary

  • vals (List[any]) – A list of values corresponding to the key, which can be any type

Returns:

list of matched key-value pairs

appian_locust.utilities.helper.find_component_by_attribute_and_index_in_dict(attribute: str, value: str, index: int, component_tree: Dict[str, Any]) Any

Find a UI component by the given attribute (label for example) in a dictionary It returns the index’th match in a depth first search of the json tree It returns the dictionary that contains the given attribute with the given value or throws an error when none is found

Parameters:
  • attribute – an attribute to search (‘label’ for example)

  • value – the value of the attribute (‘Submit’ for example)

  • index – the index of the component to find if multiple components are found with the same ‘value’ for ‘attribute’ (1 for example)

  • component_tree – the json response.

Returns:

The json object of the component

Raises:

ComponentNotFoundException if the component cannot be found.

Example

>>> find_component_by_attribute_and_index_in_dict('label', 'Submit', 1, self.json_response)

will search the json response to find the first component that has ‘Submit’ as the label

appian_locust.utilities.helper.find_component_by_attribute_in_dict(attribute: str, value: str, component_tree: Dict[str, Any], raise_error: bool = True, throw_attribute_exception: bool = False) Any

Find a UI component by the given attribute (label for example) in a dictionary It only returns the first match in a depth first search of the json tree It returns the dictionary that contains the given attribute with the given value or throws an error when none is found.

Parameters:
  • attribute – an attribute to search (‘label’ for example)

  • value – the value of the attribute (‘Submit’ for example)

  • component_tree – the json response.

  • raise_error – If set to False, will return None instead of raising an error. (Default: True)

  • throw_attribute_exception – If set to False then if the component is not found an exception is thrown using the attribute and value in the exception.

Returns:

The json object of the component

Raises:

ComponentNotFoundException if the component cannot be found.

Example

>>> find_component_by_attribute_in_dict('label', 'Submit', self.json_response)

will search the json response to find a component that has ‘Submit’ as the label

appian_locust.utilities.helper.find_component_by_index_in_dict(component_type: str, index: int, component_tree: Dict[str, Any]) Any

Find a UI component by the index of a given type of component (“RadioButtonField” for example) in a dictionary Performs a depth first search and counts quantity of the component, so the 1st is the first one It returns the dictionary that contains the given attribute with the requested index or throws an error when none is found.

Parameters:
  • component_type – type of the component(#t in the JSON response, ‘RadioButtonField’ for example)

  • index – the index of the component with the component_type (‘1’ for example - Indices start from 1)

  • component_tree – the json response

Returns:

The json object of the component

Raises:

ComponentNotFoundException if the component cannot be found.

Example

>>> find_component_by_index_in_dict('RadioButtonField', 1, self.json_response)

will search the json response to find the first component that has ‘RadioButtonField’ as the type

appian_locust.utilities.helper.find_component_by_label_and_type_dict(attribute: str, value: str, type: str, component_tree: Dict[str, Any], raise_error: bool = True) Any

Find a UI component by the given attribute (like label) in a dictionary, and the type of the component as well. (#t should match the type value passed in) It only returns the first match in a depth first search of the json tree. It returns the dictionary that contains the given attribute with the given label and type or throws an error when none is found if the raise_error value is True. Otherwise it will return None if the component cannot be found.

Parameters:
  • label – label of the component to search

  • value – the value of the label

  • type – Type of the component (TextField, StartProcessLink etc.)

  • component_tree – the json response.

  • raise_error – If set to False, will return None instead of raising an error. (Default: True)

Returns:

The json object of the component or None if the component cannot be found.

Raises:

ComponentNotFoundException if the component cannot be found.

Example

>>> find_component_by_label_and_type_dict('label', 'MyLabel', 'StartProcessLink', self.json_response)
appian_locust.utilities.helper.find_component_by_type_and_attribute_and_index_in_dict(component_tree: Dict[str, Any], type: str = '', attribute: str = '', value: str = '', index: int = 1, raise_error: bool = True) Any

Find a UI component by the given type and/or attribute with ‘value’ in a dictionary Returns the index’th match in a depth first search of the json tree Returns the dictionary that contains the given attribute with the given value or throws an error when none is found Note: Both type and attribute matching are optional, which will cause this function to return the index’th component in the tree

Parameters:

component_tree – the json response

Keyword Arguments:
  • type (str) – the component type to match against (default: ‘’)

  • attribute (str) – an attribute to search (default: ‘’)

  • value (str) – the value of the attribute (default: ‘’)

  • index (int) – the index of the component to find if multiple components match the above criteria, 1-indexed (default: 1)

  • raise_error (bool) – if this is set to false, it will return None instead of raising an error.

Returns:

The json object of the component or None if ‘raise_error’ is set to false.

Raises:
  • ComponentNotFoundException if the attribute or type checks fail.

  • Exception if the component is found but at an incorrect index.

Example

>>> find_component_by_attribute_and_index_in_dict('label', 'Submit', 1, self.json_response)

will search the json response to find the first component that has ‘Submit’ as the label

appian_locust.utilities.helper.format_label(label: str, delimiter: str | None = None, index: int = 0) str

Simply formats the string by replacing a space with underscores

Parameters:
  • label – string to be formatted

  • delimiter – If provided, string will be split by it

  • index – used with delimiter parameter, which item will be used in the “split”ed list.

Returns:

formatted string

appian_locust.utilities.helper.get_random_item(list_of_items: List[Any], exclude: List[Any] = []) Any

Gets a random item from the given list excluding the items if any provided

Parameters:
  • list_of_items – list of items of any data type

  • exclude – if any items needs to be excluded in random pick

Returns:

Randomly picked Item

Raises:

In case of no item to pick, Exception will be raised

appian_locust.utilities.helper.get_username(auth: list) str

Returns the username from an auth list :param auth: Appian Locust authorization list

Returns (str): Username from auth list

appian_locust.utilities.helper.list_filter(list_var: List[str], filter_string: str, exact_match: bool = False) List[str]

from the given list, return the list with filtered values.

Parameters:
  • list_var (list) – list of strings

  • filter_string (str) – string which will be used to filter

  • exact_match (bool, optional) – filter should be based on exact match or partial match. default is partial.

Returns:

List with filtered values

appian_locust.utilities.helper.remove_type_info(sail_dict: Dict[str, Any]) Dict[str, Any]

Returns a flattened dictionary with SAIL type info removed :param sail_dict: SAIL Dictionary to remove type information from

Returns (dict): Flattened dictionary

appian_locust.utilities.helper.repeat(num_times: int = 2, wait_time: float = 0.0) Callable

This function allows an arbitrary function to be executed an arbitrary number of times The intended use is as a decorator:

>>> @repeat(2)
... def arbitrary_function():
...     print("Hello World")
... arbitrary_function()
Hello World
Hello World
Parameters:

num_times (int) – an integer

Implicit Args:

arbitrary_function (Callable): a python function

Returns:

A reference to a function which performs the decorated function num_times times

class appian_locust.utilities.loadDriverUtils.loadDriverUtils

Bases: object

load_config(config_file: str = './config.json') dict

Load a json configuration file into a dictionary :param config_file: Location where config file can be found

Returns (dict): Dictionary containing configuration. Will also be stored in

loadDriverUtils.c

appian_locust.utilities.logger.getLogger(name: str | None = None) Logger
Parameters:

name (str, optional) – Name of the logger. it is common practice to use file name here but it can be anything.

Returns: logger object

Note

By contributing to this Open Source project, you provide Appian Corporation a non-exclusive, perpetual, royalty-free license to use your contribution for any purpose.

Contributing

  1. Fork the appian-locust repository

  2. Make any desired changes

  3. Commit changes and push to your fork

  4. Make a merge request to the upstream fork and project maintainers will review it

New Development

As new development is done to Appian Locust, the core principle of user navigation and resulting interaction should be kept in mind. Is your feature adding interaction capabilities to an existing type of page? If so, you likely want to add a new method to an existing SailUiForm type. Otherwise, you might need to create a new extention of SailUiForm and ensure that the Visitor class has the capabilities to visit the new page type. Lastly, functionality that doesn’t involve user interaction should be included in the SiteHelper class.

If you think that your development falls outside of the above criteria, you should submit an issue for the maintainers of this project to discuss your use case.

To Test Your Changes

In any test-implementation repo where you use appian-locust, change the following (assuming you’re using a Pipfile)

appian-locust = {path="../appian-locust", editable=true}

NOTE The path above assumes appian-locust is checked out locally, hence we can use a relative directory path.

And run pipenv install --skip-lock to allow you to use a local version of appian-locust without recreating the lock file. However, remember to use a lock file in your test-implementation repo.

Now you can test your changes as you normally would.

Internal Classes

Apart from our exposed API, we provide internal classes for more granular control when developing or testing.

appian_locust._locust_error_handler._format_http_error(resp: Response, uri: str, username: str) str
Taken from Response.raise_for_status. Formats the http error message,

additionally adding the username

Parameters:
  • resp (Response) – Response to generate an http error message from

  • uri (str) – URI accessed as part of the request

  • username (str) – Username of the calling user

Returns:

the http error message to use

Return type:

str

appian_locust._locust_error_handler.test_response_for_error(resp: ResponseContextManager, uri: str = 'No URI Specified', raise_error: bool = True, username: str = '', name: str = '') None

Locust relies on errors to be logged to the global_stats attribute for error handling. This function is used to notify Locust that its instances are failing and that it should fail too.

Parameters:
  • resp (Response) – a python response object from a client.get() or client.post() call in Locust tests.

  • uri (Str) – URI in the request that caused the above response.

  • username (Str) – identifies the current user when we use multiple different users for locust test)

Returns:

None

Example (Returns a HTTP 500 error):

username = 'admin'
uri = 'https://httpbin.org/status/500'
with self.client.get(uri) as resp:
  test_response_for_error(resp, uri, username)
class appian_locust._save_request_builder._SaveRequestBuilder

Bases: object

Builds a save request, that can be used to trigger saves on the UI

build() Dict[str, Any]
component(component: Dict[str, Any]) _SaveRequestBuilder
context(context: Dict[str, Any]) _SaveRequestBuilder
identifier(identifier: Dict[str, Any] | None) _SaveRequestBuilder
uuid(uuid: str) _SaveRequestBuilder
value(value: dict | list) _SaveRequestBuilder
appian_locust._save_request_builder.save_builder() _SaveRequestBuilder

Indices and tables