diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 929ce73..3ed6745 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -23,7 +23,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 39bd7c4..7630ee7 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -29,7 +29,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Pages uses: actions/configure-pages@v5 diff --git a/pyproject.toml b/pyproject.toml index 5132442..030c242 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dev = [ "invoke (>=2.2.1)", # Required for faker <6.0.0 "setuptools (>=80.0.0,<81.0.0)", + "robotframework-retryfailed>=0.2.0", ] [tool.uv.build-backend] diff --git a/src/CacheLibrary/CacheLibrary.py b/src/CacheLibrary/CacheLibrary.py index 2d48b3e..15c641a 100644 --- a/src/CacheLibrary/CacheLibrary.py +++ b/src/CacheLibrary/CacheLibrary.py @@ -2,7 +2,7 @@ from contextlib import contextmanager from datetime import datetime, timedelta from pathlib import Path -from typing import Any, Literal, TypeAlias +from typing import Any, Literal, TypeAlias, cast from pabot.pabotlib import PabotLib from robot.api import logger @@ -158,7 +158,7 @@ def cache_retrieve_value(self, key: CacheKey) -> CacheValue | None: def cache_retrieve_value_from_collection( self, key: CacheKey, - pick: Literal["first", "last", "random"] = "first", + pick: Literal["first", "last", "random", "parallel process"] = "first", remove_value: bool = True, # noqa: FBT001, FBT002 ) -> CacheValue | None: """ @@ -167,9 +167,25 @@ def cache_retrieve_value_from_collection( Will return a single value from a collection stored in the cache, or `None` if there is no value. - | `key` | Name of the collection | - | `pick=first` | How to pick a value from the collection. Can be 'first', 'last', or 'random' | - | `remove_value=True` | Should the value be removed from the collection | + | `key` | Name of the collection | + | `pick=first` | How to pick a value from the collection. Can be 'first', 'last', 'random', or 'parallel process' | + | `remove_value=True` | Should the value be removed from the collection | + + = Pick strategies = + + - `first`: pick the first value + - `last`: pick the last value + - `random`: pick a random value + - `parallel process`: pick a value unique per parallel process. + + == Pick strategy `parallel process` == + + With `parallel process`, each parallel process gets its own fixed value, preventing multiple + processes from using the same value at the same time. Within a process, the same value is + returned on each call. + + Designed to be used with `remove_value=False` and Pabot. When not using Pabot, + `parallel process` behaves the same as `first`. = Examples = @@ -177,6 +193,9 @@ def cache_retrieve_value_from_collection( Retrieve the first value from a cached collection. + The value is automatically removed from the collection. This way you will receive a new + value every time you use this keyword. + | ${user} = Cache Retrieve Value From Collection user-accounts -------------------- @@ -187,6 +206,18 @@ def cache_retrieve_value_from_collection( collection. | ${user} = Cache Retrieve Value From Collection user-accounts pick=random remove_value=${False} + + -------------------- + + == Pick parallel process value == + + Ensure each parallel process uses a different user from a cached collection of users. This + could be useful in the following scenario: + + In the application under test, a user can only have a logged in session in one browser at a + time. If you log in from a second browser, the user is logged out in the first browser. + + | ${user} = Cache Retrieve Value From Collection user-accounts pick=parallel process remove_value=${False} """ # noqa: E501 cache = self.cache_file.get() cache = self._ensure_complete_cache(cache)["COLLECTION"] @@ -211,8 +242,13 @@ def cache_retrieve_value_from_collection( index = -1 elif pick == "random": index = random.randint(0, len(values) - 1) # noqa: S311 + elif pick == "parallel process": + index = self._pick_strategy_parallel_process() else: - msg = f"Unexpected pick '{pick}'. Expected one of 'first', 'last', or 'random'." + msg = ( + f"Unexpected pick '{pick}'. " + "Expected one of 'first', 'last', 'random', or 'parallel process'." + ) raise ValueError(msg) value = values[index] @@ -221,6 +257,15 @@ def cache_retrieve_value_from_collection( return value + def _pick_strategy_parallel_process(self) -> int: + index = BuiltIn().get_variable_value("${PABOTEXECUTIONPOOLID}", 0) + try: + index = int(cast(str | int, index)) + except: # noqa: E722 + index = 0 + + return index + @keyword(tags=["value"]) def cache_store_value( self, diff --git a/tasks.py b/tasks.py index e733fb6..29d90f5 100644 --- a/tasks.py +++ b/tasks.py @@ -98,19 +98,21 @@ def test_integration(c: Context): @task def test_integration_sync(c: Context): print("Integration tests: Synchronous") - c.run("uv run robot test/integration") + c.run("uv run robot --listener RetryFailed test/integration") @task def test_integration_parallel_suite_level(c: Context): print("Integration tests: Parallel, Suite level split") - c.run("uv run pabot --pabotlib test/integration") + c.run("uv run pabot --pabotlib --listener RetryFailed test/integration") @task def test_integration_parallel_test_level(c: Context): print("Integration tests: Parallel, Test level split") - c.run("uv run pabot --testlevelsplit --pabotlib test/integration") + c.run( + "uv run pabot --testlevelsplit --pabotlib --listener RetryFailed test/integration", + ) @task diff --git a/test/integration/run-multi-value.robot b/test/integration/run-multi-value.robot index 62a87d0..03e319c 100644 --- a/test/integration/run-multi-value.robot +++ b/test/integration/run-multi-value.robot @@ -32,6 +32,23 @@ ${ITERATIONS} 50 ... float ... list ... dict +@{FIXED_INCREMENTAL_COLLECTION} +... 111 +... 222 +... 333 +... 444 +... 555 +... 666 +... 777 +... 888 +... 999 +... 000 +... aaa +... bbb +... ccc +... ddd +... eee +... fff *** Test Cases *** @@ -232,6 +249,36 @@ Store and retrieve random list data Should Be Equal ${retrieved} ${value_set}[${i}] END +Pick strategy Pabot - thread 1 + [Setup] Acquire Lock pick_strat_pabot + [Template] Test Template Pabot Pick Strategy + ${None} + [Teardown] Release Lock pick_strat_pabot + +Pick strategy Pabot - thread 2 + [Setup] Acquire Lock pick_strat_pabot + [Template] Test Template Pabot Pick Strategy + ${None} + [Teardown] Release Lock pick_strat_pabot + +Pick strategy Pabot - thread 3 + [Setup] Acquire Lock pick_strat_pabot + [Template] Test Template Pabot Pick Strategy + ${None} + [Teardown] Release Lock pick_strat_pabot + +Pick strategy Pabot - thread 4 + [Setup] Acquire Lock pick_strat_pabot + [Template] Test Template Pabot Pick Strategy + ${None} + [Teardown] Release Lock pick_strat_pabot + +Pick strategy Pabot - thread 5 + [Setup] Acquire Lock pick_strat_pabot + [Template] Test Template Pabot Pick Strategy + ${None} + [Teardown] Release Lock pick_strat_pabot + *** Keywords *** Generate Complex Collection @@ -269,3 +316,28 @@ Generate Complex Collection END RETURN ${collection} + +Test Template Pabot Pick Strategy + [Arguments] ${_} + Cache Store Collection fixed-strings @{FIXED_INCREMENTAL_COLLECTION} + ${pabot_thread_id} = Get Variable Value ${PABOTEXECUTIONPOOLID} 0 + ${expected_value} = Get From List ${FIXED_INCREMENTAL_COLLECTION} ${pabot_thread_id} + + ${other_tread_id} = Get Parallel Value For Key pick_strat_pabot_thread + ${other_tread_expected_value} = Get Parallel Value For Key pick_strat_pabot_value + IF '${other_tread_expected_value}' == '${EMPTY}' + Set Parallel Value For Key pick_strat_pabot_thread ${pabot_thread_id} + Set Parallel Value For Key pick_strat_pabot_value ${expected_value} + ELSE IF ${pabot_thread_id} != ${other_tread_id} + Should Not Be Equal As Strings ${expected_value} ${other_tread_expected_value} + ELSE + Should Be Equal As Strings ${expected_value} ${other_tread_expected_value} + END + + FOR ${_} IN RANGE ${5} + ${retrieved} = Cache Retrieve Value From Collection + ... fixed-strings + ... pick=parallel process + ... remove_value=False + Should Be Equal ${retrieved} ${expected_value} + END diff --git a/test/integration/run.robot b/test/integration/run.robot index b8dabe8..0f2165b 100644 --- a/test/integration/run.robot +++ b/test/integration/run.robot @@ -122,7 +122,12 @@ Remove stored data Should Be Equal ${postRemove} ${None} Store with expiration time - Cache Store Value random-data amazing data expire_in_seconds=1 + [Documentation] + ... Test relies on timing within the test. This timing can be messed up by locks aquired by + ... other tests. Retry stabilizes the run. + ... The retry is _not_ hiding a race condition in this situation. + [Tags] test:retry(1) + Cache Store Value random-data amazing data expire_in_seconds=2 ${preSleep} = Cache Retrieve Value random-data Sleep 2s ${postSleep} = Cache Retrieve Value random-data diff --git a/uv.lock b/uv.lock index 81e9a33..519fbed 100644 --- a/uv.lock +++ b/uv.lock @@ -313,6 +313,7 @@ dependencies = [ dev = [ { name = "invoke" }, { name = "robotframework-faker" }, + { name = "robotframework-retryfailed" }, { name = "robotframework-robocop" }, { name = "ruff" }, { name = "setuptools" }, @@ -329,6 +330,7 @@ requires-dist = [ dev = [ { name = "invoke", specifier = ">=2.2.1" }, { name = "robotframework-faker", specifier = ">=5.0.0" }, + { name = "robotframework-retryfailed", specifier = ">=0.2.0" }, { name = "robotframework-robocop", specifier = ">=7.0.0,<8.0.0" }, { name = "ruff", specifier = ">=0.9.1,<0.10.0" }, { name = "setuptools", specifier = ">=80.0.0,<81.0.0" }, @@ -360,6 +362,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f5/15/f5da87828441f53f5126dda3dd1fdf1fd73b8f5fdf490659b56dd2dc3a80/robotframework_pabot-5.2.2-py3-none-any.whl", hash = "sha256:e7b95d031e9f04b77312b04a1fd835b9791f022b3436b63b507a720bfec05bb4", size = 71801, upload-time = "2026-02-18T21:04:21.313Z" }, ] +[[package]] +name = "robotframework-retryfailed" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "robotframework" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/99/4ffc2253cbff664c93f4fe63663a0d0a68862c7bbe40aea6f324fd371ef3/robotframework-retryfailed-0.2.0.tar.gz", hash = "sha256:c134a924f480e2666916bcb019a7e255d7229bc51a3747e849bd1e7931ed6eb3", size = 7611, upload-time = "2022-10-09T20:13:48.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/80/144bca38aa894ec876f7943d3fd62fa57d9be937cc0be9e5d7e3df9837e3/robotframework_retryfailed-0.2.0-py3-none-any.whl", hash = "sha256:b389dadb446a4d7356d7e953aabfdf2b0497d51df11d3da80abe9d4a8a0992c3", size = 8638, upload-time = "2022-10-09T20:13:46.396Z" }, +] + [[package]] name = "robotframework-robocop" version = "7.2.0"