Best Practices
Git Repository
We’re using two branches for development, main and develop.
mainis the branch that production scenarios are deployed from. This branch is protected and can be merged to it only through a merge request.
developis the branch used for running scenarios in test environment, or during their development.
Before deploying scenarios into production we advice asking your colleagues to review your code.
This can be done by creating a merge request from develop to main branch.
We setup a GitLab CI pipeline that runs with every commit. It runs code formatting, linting and static type checking.
We use ruff for linting and formatting, run through pre-commit, together with mypy for static type checking, to ensure that our code is formatted correctly and follows our best practices.
You can run these checks also locally by running:
$ pre-commit run --all-files
Aiviro Commands
Get, See, Wait-For
The distinction between the get(), see() and wait_for() commands lies in their behavior when searching for elements.
Getreturns the desired element orNoneif it’s not found, making it suitable when you need to make a decision.On the other hand,
Seereturns the desired element or raises aCheckCommandErrorif it’s not found, making it ideal for scenarios where obtaining the element is critical, ensuring a 100% success rate.
Wait-Forwaits for the element to appear for a given amount of time before raising aSearchObjectError.
# using get command
if r.get(aiviro.Text("Save ?"), ignore_exception=True):
r.click(aiviro.Button("Yes"))
# using see command
r.see(aiviro.Button("Submit"))
# using wait-for command
if r.wait_for(
aiviro.Text("Select year period"),
timeout=15,
ignore_timeout_exception=True, # ignores raising SearchObjectError
):
current_year = datetime.now().strftime("%Y")
r.click(aiviro.Text(current_year))
Working area & Masks
The distinction between using a working area and applying masks is essential.
A working area defines a designated region for the user’s interactions, ensuring that subsequent commands operate within this specified area. This limitation enhances the precision and clarity of element identification, reducing potential confusion with similarly named elements.
On the other hand, masks involve overlaying a screen with a cover, selectively blocking out specific sections. Masks are particularly valuable when there’s a need to disregard certain screen portions, allowing the user to focus solely on the relevant elements, and effectively excluding distractions.
Check out the set_working_area() and add_mask() methods for more information.
Static Robot
When something is not recognized correctly in a production scenario, it can be challenging to rectify.
In such cases, we can utilize the StaticRobot to verify the correct recognition of specific elements within an image.
See the example below, where we will use an image from the logs to identify the desired object.
import aiviro
from aiviro.core.utils import file_utils
if __name__ == '__main__':
aiviro.init_logging()
img = file_utils.load_image("2022-02-11_15-22-44-51_aivirocore.services.FindService_INFO.png")
r = aiviro.create_static_robot()
r.set_image(img)
boxes = r.get(aiviro.Text("Object to find"))
print(boxes)
Production vs Testing
When defining flows, we set the environment variable AIVIRO_DEBUG_KEY, which determines whether the scenario is executed in a testing or production environment.
AIVIRO_DEBUG_VALUE == 1- signifies the testing environment.
AIVIRO_DEBUG_VALUE == 0- designates the production environment.
See the example below, where we define different values for the same variables, depending on the environment.
from aiviro import AIVIRO_DEBUG_VALUE
INVOICE_FOLDER = "C:\\Documents" if AIVIRO_DEBUG_VALUE else "Z:\\Prijate faktury"
DB_CATEGORY = "Cvicne DB" if AIVIRO_DEBUG_VALUE else "Produkcni DB"
DB_NAME = "Skoleni" if AIVIRO_DEBUG_VALUE else "Ostra"
Clean code
Re-using elements
There is a big difference between passing Search Objects as parameters and passing Area or BoundBox as parameters.
Search Objectsdefines a way to search for an element, but it doesn’t contain any information about the element itself.
Bound Boxesare the result of a search, and they contain information as element’s coordinates, text, etc.
import aiviro
# simple element
box = r.get(aiviro.Button("OK"))
if box:
r.click(box)
# element with parameters
element = aiviro.OnTheRight(
aiviro.Text("Projects", aiviro.find_method.EQUAL),
aiviro.Text("Dashboards", aiviro.find_method.EQUAL),
)
box = r.wait_for(element)
r.click(box)
# OR
r.click(r.wait_for(element))
# several elements
elements = [
aiviro.Input("App Title"),
aiviro.Input("Username"),
aiviro.Input("Password"),
]
r.wait_for(*elements, timeout=15)
Using OR
When using OR, the first element that is found is returned, see Or for more information.
import aiviro
# NOT preferred way to do it
try:
pass_input = r.wait_for(aiviro.Input("Heslo"))
except aiviro.SearchObjectError:
pass_input = r.wait_for(aiviro.Input("Heslc"))
# preferred way to do it, using OR
password_input = r.wait_for(
aiviro.Or(
aiviro.Input("Heslo"),
aiviro.Input("Heslc")
)
)
# you can access or_index variable to see which element was found
print(password_input.or_index)
1 # 'Heslc' was found
Split the code
Split the code into logical sections or methods. It’s more readable and easier to maintain.
import aiviro
# NOT preferred way to do it
r.add_working_area_checkpoint("desktop")
r.right_click(aiviro.Icon("Helios", element_index=-1))
run_as_admin = r.wait_for(aiviro.Text("Run as administrator"))
r.click(run_as_admin)
r.wait_for(aiviro.Text("User Account Control"))
r.clear_and_type(aiviro.Input("User name"), "user-name")
r.clear_and_type(aiviro.Input("Password"), "password")
r.click(aiviro.Button("Yes"))
with r.set_working_area_by_checkpoint("desktop"):
pass
# preferred way to do it
checkpoint_name = "desktop"
r.add_working_area_checkpoint(checkpoint_name)
r.right_click(aiviro.Icon("Helios", element_index=-1))
run_as_admin = r.wait_for(aiviro.Text("Run as administrator"))
r.click(run_as_admin)
r.wait_for(aiviro.Text("User Account Control"))
r.clear_and_type(aiviro.Input("User name"), "user-name")
r.clear_and_type(aiviro.Input("Password"), "password")
r.click(aiviro.Button("Yes"))
with r.set_working_area_by_checkpoint(checkpoint_name):
pass
Python
Python Best Practices - https://gist.github.com/sloria/7001839
Python Anti-Patterns - https://docs.quantifiedcode.com/python-anti-patterns/index.html
Typing & Type hints - https://docs.python.org/3.11/library/typing.html
Floating point
When working with floating-point numbers, it’s recommended to use the decimal module to avoid rounding errors.
from decimal import Decimal
a = Decimal(1) # integer
b = Decimal(1.2) # float
c = Decimal((0, (1, 2), -1)) # piecewise float = sign, digits, exponent -> + 12 * 10^-1
d = Decimal("1") # integer as a string
e = Decimal("1.2") # float as a string
When working with decimal numbers, it’s recommended to always create a Decimal object from a string because it preserves the value as intended.
When you specify a Decimal from an integer or a float, the entire binary representation of the number is copied.
from decimal import Decimal
>>> Decimal(1.1) # float
Decimal('1.100000000000000088817841970012523233890533447265625')
>>> Decimal("1.1") # string
Decimal('1.1')
>>> Decimal(0.1 + 0.2)
Decimal('0.3000000000000000444089209850062616169452667236328125')
>>> Decimal(0.1) + Decimal(0.2)
Decimal('0.3000000000000000166533453694')
>>> Decimal("0.1") + Decimal("0.2")
Decimal('0.3')
In the example below, we can see how to round number to two decimal places using standard rounding, rounding down (floor) and rounding up (ceiling).
from decimal import Decimal, ROUND_UP, ROUND_DOWN
# Standard rounding (nearest number rounding)
>>> Decimal("1.455").quantize(Decimal("0.01"))
Decimal('1.46')
>>> Decimal("1.454").quantize(Decimal("1e-2"))
Decimal('1.45')
# Round down (floor)
>>> Decimal("1.455").quantize(Decimal("0.01"), ROUND_DOWN)
Decimal('1.45')
>>> Decimal("1.454").quantize(Decimal("1e-2"), ROUND_DOWN)
Decimal('1.45')
# Round up (ceiling)
>>> Decimal("1.455").quantize(Decimal("0.01"), ROUND_UP)
Decimal('1.46')
>>> Decimal("1.454").quantize(Decimal("1e-2"), ROUND_UP)
Decimal('1.46')
Pydantic Models and Immutability
We use Pydantic V2 models to define classes that store and pass data between layers.
Besides removing boilerplate (__init__, __repr__, __eq__), Pydantic gives you
runtime validation of the data and easy serialization via model_dump() / model_validate().
To make a model immutable, set frozen=True in its model_config.
This means that once an instance is created, its fields cannot be modified.
It provides several benefits:
Safety: Immutability ensures that the data cannot be accidentally changed, which can prevent bugs.
Hashability: Frozen models can be used as dictionary keys or added to sets, as they implement
__hash__().
from pydantic import BaseModel, ConfigDict
class User(BaseModel):
model_config = ConfigDict(frozen=True)
id: int
name: str
email: str
When dealing with optional values, set them to None by default.
This clearly indicates that the field is optional and not required during the creation of an instance.
from pydantic import BaseModel, ConfigDict
class UserProfile(BaseModel):
model_config = ConfigDict(frozen=True)
id: int
username: str
bio: str | None = None
website: str | None = None
For a default mutable value, such as a list or dictionary, use Field with a default_factory.
This ensures that each instance gets its own separate copy of the mutable object.
from pydantic import BaseModel, ConfigDict, Field
class UserSettings(BaseModel):
model_config = ConfigDict(frozen=True)
id: int
preferences: list[str] = Field(default_factory=list)
Required values should not have a default value.
This ensures that they must be provided when creating an instance, and Pydantic raises a
ValidationError if they are missing.
from decimal import Decimal
from pydantic import BaseModel, ConfigDict
class Product(BaseModel):
model_config = ConfigDict(frozen=True)
id: int
name: str
price: Decimal
description: str = "No description available"
Pathlib instead of OS library
The pathlib library, provides an object-oriented approach to handling filesystem paths.
It is a more intuitive and flexible alternative to the traditional os and os.path modules.
Benefits of using pathlib over os:
Readability:
pathlibmakes code more readable by using objects to represent paths, which allows for more expressive and chainable methods.Cross-Platform Compatibility:
pathlibhandles different path formats across operating systems, reducing the need for platform-specific code.Conciseness: Many common file operations can be performed in fewer lines of code compared to the
osmodule.Integration:
pathlibintegrates well with other Python libraries and functions that accept path-like objects.
You can easily create and manipulate path objects:
from pathlib import Path
# Create a Path object
path = Path('/home/user/documents')
# Join paths
new_path = path / 'myfile.txt'
# Get the parent directory
parent = path.parent
It provides methods for common file operations:
from pathlib import Path
path = Path('/home/user/documents/myfile.txt')
# Check if a path exists
if path.exists():
print(f"{path} exists")
dir_path = Path('/home/user/documents')
# Create a new directory
dir_path.mkdir(parents=True, exist_ok=True)
# List all files in a directory
for file in dir_path.iterdir():
print(file)
# Recursively list all PDF files in a directory
for file in dir_path.glob('*.[pP][dD][fF]'):
print(file)