Best Practices ============== Git Repository -------------- We're using two branches for development, ``main`` and ``develop``. * ``main`` is the branch that production scenarios are deployed from. This branch is protected and can be merged to it only through a merge request. * ``develop`` is 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 basic checks, such as syntax formatting, linting, etc. We use tools as ``black``, ``pycln``, ``isort``, ``flake8``, ``pylint`` and ``pre-commit`` to ensure that our code is formatted correctly and follows our best practices. You can run these checks also locally by running: .. code-block:: bash $ pre-commit run --all-files Aiviro Commands --------------- Get, See, Wait-For ^^^^^^^^^^^^^^^^^^ The distinction between the :meth:`~.BaseRobot.get`, :meth:`~.BaseRobot.see` and :meth:`~.BaseRobot.wait_for` commands lies in their behavior when searching for elements. * ``Get`` returns the desired element or ``None`` if it's not found, making it suitable when you need to make a decision. * On the other hand, ``See`` returns the desired element or raises a :class:`~.CheckCommandError` if it's not found, making it ideal for scenarios where obtaining the element is critical, ensuring a 100% success rate. * ``Wait-For`` waits for the element to appear for a given amount of time before raising a :class:`~.SearchObjectError`. .. code-block:: python # 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 :meth:`~.BaseRobot.set_working_area` and :meth:`~.BaseRobot.add_mask` methods for more information. .. _best practices logging: Logging ^^^^^^^ For detailed information about what type of logs are available and how to use them, see :ref:`configuration logging` section. In case you wish to log additional information, you can simply use Python logging module. .. code-block:: python import aiviro import logging if __name__ == "__main__": # name of the logger has to start with "aiviro." my_logger = logging.getLogger("aiviro.my_logger") logger.info("Starting scenario") r = aiviro.create_web_robot() try: r.wait_for(aiviro.Text("Hello World")) except aiviro.SearchObjectError: my_logger.exception("Hello World not found") finally: r.close() logger.info("Ending scenario") 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 :class:`~.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. .. code-block:: python import aiviro from aiviro.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. .. code-block:: python 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 :ref:`Search Objects` as parameters and passing :class:`~.Area` or :class:`~.BoundBox` as parameters. * ``Search Objects`` defines a way to search for an element, but it doesn't contain any information about the element itself. * ``Bound Boxes`` are the result of a search, and they contain information as element's coordinates, text, etc. .. code-block:: python 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.text_find_methods.EQUAL), aiviro.Text("Dashboards", aiviro.text_find_methods.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 :class:`~.Or` for more information. .. code-block:: python 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. .. code-block:: python 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 Hard-coded values ^^^^^^^^^^^^^^^^^ The practice of hard-coding values into a script, especially when the same value is used in multiple places, is not a recommended approach. This is because when the value needs to be changed, it must be updated at all occurrences, which is prone to oversight. A better practice is to define the value as a constant at the beginning of the script or block and then use this constant throughout. .. code-block:: python # discouraged approach for i in range(10): print(f"Processing [{i}/10]") # recommended approach N_TIMES = 10 for i in range(N_TIMES): print(f"Processing [{i}/{N_TIMES}]") Constants ^^^^^^^^^ Our goal is to define constants that have a common association. For instance, let's consider a folder structure in an email directory with a top-level folder named ``Invoices`` which further contains two subfolders, ``processed`` and ``manual``. .. code-block:: python # discouraged approach INVOICES = "INBOX.Invoices" INVOICES_MANUAL = "INBOX.Invoices.manual" INVOICES_PROCESSED = "INBOX.Invoices.processed" # recommended approach class EmailFolders: POSTFIX_MANUAL = "manual" POSTFIX_PROCESSED = "processed" INVOICES = "INBOX.Invoices" INVOICES_MANUAL = f"{INVOICES}.{POSTFIX_MANUAL}" INVOICES_PROCESSED = f"{INVOICES}.{POSTFIX_PROCESSED}" TESTING = "INBOX.Testing" TESTING_MANUAL = f"{TESTING}.{POSTFIX_MANUAL}" TESTING_PROCESSED = f"{TESTING}.{POSTFIX_PROCESSED}" Exception handling ^^^^^^^^^^^^^^^^^^ When handling exceptions, it's important to be as specific as possible. This is because if a general exception is used, it can be difficult to determine the cause of the error. .. code-block:: python # discouraged approach try: r.click(aiviro.Button("OK")) except Exception: print("Something went wrong") # recommended approach try: r.click(aiviro.Button("OK")) except aiviro.SearchObjectError: print("Button OK not found") .. code-block:: python try: # raise different exceptions except aiviro.SearchObjectError: # handle SearchObjectError except (ValueError, KeyError): # handle ValueError and KeyError except RuntimeError: # handle RuntimeError If-else statement ^^^^^^^^^^^^^^^^^^ It is advisable to avoid using the ``else`` keyword when not necessary, as it can make the code cleaner and more concise. .. code-block:: python # discouraged approach if condition: return result else: return None # recommended approach if condition: return result return None Code Indentation ^^^^^^^^^^^^^^^^ It's recommended to aim for reduced indentation levels to enhance code readability. .. code-block:: python # discouraged approach if condition: calc = get_value() if calc > 42: calc2 = get_value2() if calc2 == 100: return "success" else: return "calc2 failed" else: return "calc1 failed:" return None # recommended approach if not condition: return None calc = get_value() if calc <= 42: return "calc1 failed" calc2 = get_value2() if calc2 != 100: return "calc2 failed" return "success" Floating point ^^^^^^^^^^^^^^ When working with floating-point numbers, it's recommended to use the `decimal `_ module to avoid rounding errors. .. code-block:: python 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. .. code-block:: python 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). .. code-block:: python 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')