diff --git a/pyproject.toml b/pyproject.toml index 1fce25d..6da7050 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,11 @@ classifiers = [ "Typing :: Typed", ] requires-python = ">=3.10" -dependencies = ["robotframework (>=5.0.1)", "robotframework-pabot (>=2.2.0)"] +dependencies = [ + "jsonpickle>=4.1.1", + "robotframework (>=5.0.1)", + "robotframework-pabot (>=2.2.0)", +] [dependency-groups] dev = [ diff --git a/src/CacheLibrary/CacheLibrary.py b/src/CacheLibrary/CacheLibrary.py index 72538e4..138efb0 100644 --- a/src/CacheLibrary/CacheLibrary.py +++ b/src/CacheLibrary/CacheLibrary.py @@ -1,35 +1,27 @@ -import json import random -from collections.abc import Generator from contextlib import contextmanager from datetime import datetime, timedelta from pathlib import Path -from typing import Any, Literal, TypeAlias, TypedDict +from typing import Any, Literal, TypeAlias from pabot.pabotlib import PabotLib from robot.api import logger from robot.api.deco import keyword, library -from robot.errors import RobotError from robot.libraries.BuiltIn import BuiltIn from .__version__ import __version__ - -CacheKey: TypeAlias = str -CacheValue: TypeAlias = str | bool | int | float | list | dict -CacheValueType: TypeAlias = Literal["COLLECTION", "VALUE"] -CACHE_VALUE_TYPES: tuple[CacheValueType, ...] = ("COLLECTION", "VALUE") - - -class CacheEntry(TypedDict): - """ - Base data struct for cache entry - """ - - value: CacheValue - expires: str - - -CacheContents: TypeAlias = dict[CacheValueType, dict[CacheKey, CacheEntry]] +from .cache_file.base import CacheFile +from .cache_file.json_file import JsonCacheFile +from .cache_file.pickle_file import PickleCacheFile +from .constants import ( + CACHE_VALUE_TYPES, + CacheContents, + CacheEntry, + CacheKey, + CacheValue, + CacheValueType, +) +from .util.lock import lock KwName: TypeAlias = str KwArgs: TypeAlias = Any @@ -91,7 +83,7 @@ class CacheLibrary: | RETURN ${new_token} """ - parallel_value_key = "robotframework-cache" + cache_file: CacheFile def __init__( self, @@ -104,10 +96,29 @@ def __init__( | `file_size_warning_bytes` | Log warning when the cache exceeds this size | | `default_expire_in_seconds=3600` | After how many seconds should a stored value expire. Can be overwritten when a value is stored | """ # noqa: D205, E501 - self.pabotlib = PabotLib() - self.file_path = Path(file_path) self.file_size_warning_bytes = file_size_warning_bytes self.default_expire_in_seconds = default_expire_in_seconds + self.pabotlib = PabotLib() + + path = Path(file_path) + + if path.suffix == ".json": + self.cache_file = JsonCacheFile( + path, + self.pabotlib, + file_cleanup_handler=self._cleanup_cache, + ) + elif path.suffix == ".pkl": + self.cache_file = PickleCacheFile( + path, + self.pabotlib, + file_cleanup_handler=self._cleanup_cache, + ) + else: + msg = ( + f"Unexpected cache file extension '{path.suffix}'. Expected one of '.json', '.pkl'" + ) + raise ValueError(msg) @keyword(tags=["value"]) def cache_retrieve_value(self, key: CacheKey) -> CacheValue | None: @@ -126,12 +137,13 @@ def cache_retrieve_value(self, key: CacheKey) -> CacheValue | None: | ${session_token} = Cache Retrieve Value user-session """ - cache = self._open_cache_file()["VALUE"] + cache = self.cache_file.get() + cache = self._ensure_complete_cache(cache)["VALUE"] - if key not in cache: + entry = cache.get(key, None) + if not entry: return None - entry = cache[key] if self._entry_is_expired(entry): self.cache_remove_value(key) return None @@ -172,7 +184,8 @@ def cache_retrieve_value_from_collection( | ${user} = Cache Retrieve Value From Collection user-accounts pick=random remove_value=${False} """ # noqa: E501 - cache = self._open_cache_file()["COLLECTION"] + cache = self.cache_file.get() + cache = self._ensure_complete_cache(cache)["COLLECTION"] if key not in cache: return None @@ -305,19 +318,16 @@ def _store_cache_entry( if expire_in_seconds == "default": expire_in_seconds = self.default_expire_in_seconds - expires = (datetime.now() + timedelta(seconds=expire_in_seconds)).isoformat() - cache_entry: CacheEntry = { - "value": value, - "expires": expires, - } + expires = datetime.now() + timedelta(seconds=expire_in_seconds) + cache_entry = CacheEntry( + value=value, + expires=expires.isoformat(), + ) - with self._lock("cachelib-edit"): - cache = self._open_cache_file() + with self.edit_cache() as cache: cache[value_type][key] = cache_entry - self.pabotlib.set_parallel_value_for_key(self.parallel_value_key, cache) - self._store_json_file(self.file_path, cache) - + self.cache_file.store(cache) return cache_entry @keyword(tags=["collection"]) @@ -354,21 +364,18 @@ def cache_remove_value_from_collection( | ${session} = Cache Retrieve Value From Collection user-sessions remove_value=${False} | Cache Remove Value From Collection user-sessions value=${session} """ # noqa: E501 - with self._lock("cachelib-edit"): - cache = self._open_cache_file() - collection_cache = cache["COLLECTION"] - - if key not in collection_cache: + with self.edit_cache() as cache: + entry = cache["COLLECTION"].get(key, None) + if not entry: return - values = collection_cache[key]["value"] + values = entry["value"] if not isinstance(values, list): return - values = self._remove_value_from_collection(key, values, index=index, value=value) + self._remove_value_from_collection(key, values, index=index, value=value) - self.pabotlib.set_parallel_value_for_key(self.parallel_value_key, cache) - self._store_json_file(self.file_path, cache) + self.cache_file.store(cache) def _remove_value_from_collection( self, @@ -397,6 +404,10 @@ def _remove_value_from_collection( return col_values if value is not None: + if type(value).__name__ == "Secret": + msg = "Removing Secret from collection by value is not supported." + raise ValueError(msg) + try: col_values.remove(value) except ValueError as e: @@ -449,15 +460,13 @@ def _remove_cache_entry( key: CacheKey, value_type: CacheValueType, ) -> None: - with self._lock("cachelib-edit"): - cache = self._open_cache_file() - + with self.edit_cache() as cache: if key not in cache[value_type]: return del cache[value_type][key] - self.pabotlib.set_parallel_value_for_key(self.parallel_value_key, cache) - self._store_json_file(self.file_path, cache) + + self.cache_file.store(cache) @keyword def cache_reset(self) -> None: @@ -465,9 +474,8 @@ def cache_reset(self) -> None: Remove all values from the cache. """ empty_cache = self._ensure_complete_cache({}) - with self._lock("cachelib-edit"): - self.pabotlib.set_parallel_value_for_key(self.parallel_value_key, empty_cache) - self._store_json_file(self.file_path, empty_cache) + with self.edit_cache(): + self.cache_file.store(empty_cache) @keyword(tags=["value"]) def run_keyword_and_cache_output( @@ -551,49 +559,24 @@ def _ensure_complete_cache(self, cache: CacheContents) -> CacheContents: cache.setdefault(value_type, {}) return cache - def _open_cache_file(self) -> CacheContents: - shared_cache = self.pabotlib.get_parallel_value_for_key(self.parallel_value_key) - # If not set, `shared_cache` will be an empty string. - if isinstance(shared_cache, dict): - return shared_cache - - cache_file_contents = self._read_json_file(self.file_path) - - file_size = self.file_path.stat().st_size + def _cleanup_cache(self, cache: CacheContents) -> CacheContents: + file_size = self.cache_file.get_size() if file_size > self.file_size_warning_bytes: logger.warn( - f"Large cache file '{self.file_path}'. File is {round(file_size / 1024, 1)}Kb. " + f"Large cache file '{self.cache_file.file_path}'. " + f"File is {round(file_size / 1024, 1)}Kb. " "There might be an issue with the caching mechanism.", ) - cache_contents: CacheContents = self._ensure_complete_cache({}) - for value_type, contents in cache_file_contents.items(): + # Remove expired entries + cleaned_cache: CacheContents = self._ensure_complete_cache({}) + for value_type, contents in cache.items(): for key, entry in contents.items(): if self._entry_is_expired(entry): continue - cache_contents[value_type][key] = entry - - self.pabotlib.set_parallel_value_for_key(self.parallel_value_key, cache_contents) - self._store_json_file(self.file_path, cache_contents) - return cache_contents + cleaned_cache[value_type][key] = entry - def _read_json_file(self, path: Path) -> CacheContents: - with self._lock(f"cachelib-file-{path}"): - try: - with path.open("r", encoding="utf8") as f: - return json.load(f) - except (RobotError, KeyboardInterrupt, SystemExit): - raise - except Exception: # noqa: BLE001 - # Reset/create the file - empty_cache = self._ensure_complete_cache({}) - with path.open("w", encoding="utf8") as f: - json.dump(empty_cache, f) - return empty_cache - - def _store_json_file(self, path: Path, contents: CacheContents) -> None: - with self._lock(f"cachelib-file-{path}"), path.open("w", encoding="utf8") as f: - return json.dump(contents, f) + return cleaned_cache def _entry_is_expired(self, entry: CacheEntry) -> bool: now = datetime.now() @@ -601,9 +584,9 @@ def _entry_is_expired(self, entry: CacheEntry) -> bool: return (expires - now).total_seconds() < 0 @contextmanager - def _lock(self, name: str) -> Generator[None, Any, None]: - try: - self.pabotlib.acquire_lock(name) - yield - finally: - self.pabotlib.release_lock(name) + def edit_cache(self): + """Lock the cache for editing. Yields recent cache.""" + with lock(self.pabotlib, "cachelib-edit"): + cache = self.cache_file.get() + cache = self._ensure_complete_cache(cache) + yield cache diff --git a/src/CacheLibrary/cache_file/__init__.py b/src/CacheLibrary/cache_file/__init__.py new file mode 100644 index 0000000..a59e2a1 --- /dev/null +++ b/src/CacheLibrary/cache_file/__init__.py @@ -0,0 +1,3 @@ +""" +Cache store management. +""" diff --git a/src/CacheLibrary/cache_file/base.py b/src/CacheLibrary/cache_file/base.py new file mode 100644 index 0000000..6b8b040 --- /dev/null +++ b/src/CacheLibrary/cache_file/base.py @@ -0,0 +1,148 @@ +from collections.abc import Callable +from pathlib import Path +from uuid import uuid4 + +from pabot.pabotlib import PabotLib +from robot.errors import RobotError + +from CacheLibrary.constants import CacheContents +from CacheLibrary.util.lock import lock + + +class CacheFile: + """ + Cache library store. + """ + + file_path: Path + + _process_cache: CacheContents | None = None + _process_cache_updated: str = "" + + _parallel_value_key_cache = "robotframework-cache" + _parallel_value_key_updated = "robotframework-cache-updated" + + def __init__( + self, + file_path: Path, + pabotlib: PabotLib, + *, + file_cleanup_handler: Callable[[CacheContents], CacheContents] | None = None, + ) -> None: + self.file_path = file_path + self._pabotlib = pabotlib + self._file_cleanup_handler = file_cleanup_handler + + def get(self) -> CacheContents: + """Get the full cache""" + process_cache = self._open_from_process_cache() + if process_cache: + return process_cache + + shared_cache = self._open_from_shared_cache() + if shared_cache: + return shared_cache + + return self._open_from_file_cache() + + def _open_from_process_cache(self) -> CacheContents | None: + if self._process_cache is None: + return None + + shared_cache_updated = self._pabotlib.get_parallel_value_for_key( + self._parallel_value_key_updated, + ) + if shared_cache_updated == "": + # Never set + return None + + if shared_cache_updated != self._process_cache_updated: + return None + + return self._process_cache + + def _open_from_shared_cache(self) -> CacheContents | None: + shared_cache = self._pabotlib.get_parallel_value_for_key(self._parallel_value_key_cache) + + if shared_cache == "": + # Never set + return None + + if not isinstance(shared_cache, bytes): + # Unexpected type + return None + + shared_cache_updated = self._pabotlib.get_parallel_value_for_key( + self._parallel_value_key_updated, + ) + if shared_cache_updated == "" or not isinstance(shared_cache_updated, str): + shared_cache_updated = str(uuid4()) + + decoded = self._decode(shared_cache) + self._store_in_process_cache(decoded, shared_cache_updated) + return decoded + + def _open_from_file_cache(self) -> CacheContents: + cache_contents = self._open_cache_file() + if not cache_contents: + return {} + + if not self._file_cleanup_handler: + return cache_contents + + cache_contents = self._file_cleanup_handler(cache_contents) + self.store(cache_contents) + return cache_contents + + def _open_cache_file(self) -> CacheContents: + try: + with ( + lock(self._pabotlib, f"cachelib-file-{self.file_path}"), + self.file_path.open("rb") as f, + ): + raw = f.read() + + return self._decode(raw) + except (RobotError, KeyboardInterrupt, SystemExit): + raise + except Exception: # noqa: BLE001 + # Reset/create the file + empty_cache = {} + self.store(empty_cache) + return empty_cache + + def store(self, contents: CacheContents) -> None: + """Store contents to the cache file""" + store_id = str(uuid4()) + self._store_in_process_cache(contents, store_id) + + encoded = self._encode(contents) + self._store_in_shared_cache(encoded, store_id) + self._store_in_file_cache(encoded) + + def _store_in_process_cache(self, contents: CacheContents, store_id: str) -> None: + self._process_cache = contents + self._process_cache_updated = store_id + + def _store_in_shared_cache(self, encoded_contents: bytes, store_id: str) -> None: + self._pabotlib.set_parallel_value_for_key(self._parallel_value_key_cache, encoded_contents) + self._pabotlib.set_parallel_value_for_key(self._parallel_value_key_updated, store_id) + + def _store_in_file_cache(self, encoded_contents: bytes) -> None: + with ( + lock(self._pabotlib, f"cachelib-file-{self.file_path}"), + self.file_path.open("wb") as f, + ): + f.write(encoded_contents) + + def _encode(self, cache: CacheContents) -> bytes: + """Encode Python object CacheContents to file contents.""" + raise NotImplementedError + + def _decode(self, raw: bytes) -> CacheContents: + """Encode file contents to Python object CacheContents.""" + raise NotImplementedError + + def get_size(self) -> int: + """Get cache size in bytes""" + return self.file_path.stat().st_size diff --git a/src/CacheLibrary/cache_file/json_file.py b/src/CacheLibrary/cache_file/json_file.py new file mode 100644 index 0000000..377579b --- /dev/null +++ b/src/CacheLibrary/cache_file/json_file.py @@ -0,0 +1,35 @@ +from typing import cast + +import jsonpickle + +from CacheLibrary.constants import CacheContents +from CacheLibrary.util.dotdict import dotdict_to_dict + +from .base import CacheFile + + +class JsonCacheFile(CacheFile): + """ + Cache library store for .json files. + """ + + def _decode(self, raw: bytes) -> CacheContents: + decoded = jsonpickle.decode(raw, on_missing="error") # noqa: S301 + + if not isinstance(decoded, dict): + msg = "Failed to decode .json file" + raise TypeError(msg) + + return cast(CacheContents, decoded) + + def _encode(self, cache: CacheContents) -> bytes: + cache = dotdict_to_dict(cache) + + encoded = jsonpickle.encode(cache) + if isinstance(encoded, str): + return encoded.encode("utf-8") + if isinstance(encoded, bytes): + return encoded + + msg = f"Failed to encode cache. Got type '{type(encoded)}'. This should never happen." + raise ValueError(msg) diff --git a/src/CacheLibrary/cache_file/pickle_file.py b/src/CacheLibrary/cache_file/pickle_file.py new file mode 100644 index 0000000..6aea150 --- /dev/null +++ b/src/CacheLibrary/cache_file/pickle_file.py @@ -0,0 +1,27 @@ +import pickle +from typing import cast + +from CacheLibrary.constants import CacheContents +from CacheLibrary.util.dotdict import dotdict_to_dict + +from .base import CacheFile + + +class PickleCacheFile(CacheFile): + """ + Cache library store for .pkl files. + """ + + def _decode(self, raw: bytes) -> CacheContents: + decoded = pickle.loads(raw) # noqa: S301 + + if not isinstance(decoded, dict): + msg = "Failed to decode .pkl file" + raise TypeError(msg) + + return cast(CacheContents, decoded) + + def _encode(self, cache: CacheContents) -> bytes: + cache = dotdict_to_dict(cache) + + return pickle.dumps(cache) diff --git a/src/CacheLibrary/constants.py b/src/CacheLibrary/constants.py new file mode 100644 index 0000000..a26cb45 --- /dev/null +++ b/src/CacheLibrary/constants.py @@ -0,0 +1,25 @@ +from typing import Literal, TypeAlias, TypedDict + +try: + from robot.api.types import Secret # pyright: ignore[reportRedeclaration] +except ImportError: + # Before Robot 7.4 + Secret: TypeAlias = None + + +CacheKey: TypeAlias = str +CacheValue: TypeAlias = str | bool | int | float | list | dict | Secret +CacheValueType: TypeAlias = Literal["COLLECTION", "VALUE"] +CACHE_VALUE_TYPES: tuple[CacheValueType, ...] = ("COLLECTION", "VALUE") + + +class CacheEntry(TypedDict): + """ + Base data struct for cache entry + """ + + value: CacheValue + expires: str + + +CacheContents: TypeAlias = dict[CacheValueType, dict[CacheKey, CacheEntry]] diff --git a/src/CacheLibrary/util/__init__.py b/src/CacheLibrary/util/__init__.py new file mode 100644 index 0000000..e64f3d0 --- /dev/null +++ b/src/CacheLibrary/util/__init__.py @@ -0,0 +1,3 @@ +""" +Random shared functions that have not better home. +""" diff --git a/src/CacheLibrary/util/dotdict.py b/src/CacheLibrary/util/dotdict.py new file mode 100644 index 0000000..7959b61 --- /dev/null +++ b/src/CacheLibrary/util/dotdict.py @@ -0,0 +1,16 @@ +from robot.utils.dotdict import DotDict + + +def dotdict_to_dict(inp: dict | DotDict) -> dict: + """Recursively cast every DotDict instance to plain dict""" + res = {} + for key, val in inp.items(): + if isinstance(val, DotDict): + val = dict(val) # noqa: PLW2901 + + if isinstance(val, dict): + val = dotdict_to_dict(val) # noqa: PLW2901 + + res[key] = val + + return res diff --git a/src/CacheLibrary/util/lock.py b/src/CacheLibrary/util/lock.py new file mode 100644 index 0000000..1c021ad --- /dev/null +++ b/src/CacheLibrary/util/lock.py @@ -0,0 +1,15 @@ +from collections.abc import Generator +from contextlib import contextmanager +from typing import Any + +from pabot.pabotlib import PabotLib + + +@contextmanager +def lock(pabotlib: PabotLib, name: str) -> Generator[None, Any, None]: + """Context manager that acquires and releases pabot locks""" + try: + pabotlib.acquire_lock(name) + yield + finally: + pabotlib.release_lock(name) diff --git a/tasks.py b/tasks.py index 670d84a..e733fb6 100644 --- a/tasks.py +++ b/tasks.py @@ -118,8 +118,8 @@ def test_acceptance(c: Context): _run_multiple_tasks( c, ( - test_acceptance_parallel_test_level, - test_acceptance_parallel_test_level, + test_acceptance_sync, + test_acceptance_parallel_suite_level, test_acceptance_parallel_test_level, ), ) diff --git a/test/acceptance/run.robot b/test/acceptance/run.robot index b04ea26..cfdf0cb 100644 --- a/test/acceptance/run.robot +++ b/test/acceptance/run.robot @@ -8,7 +8,7 @@ Library FakerLibrary *** Variables *** ${DEFAULT_EXPIRES_IN_SECONDS} ${3600} -${EMPTY_CACHE} \{"COLLECTION": \{\}, "VALUE": \{\}\} +&{EMPTY_CACHE} COLLECTION=&{EMPTY} VALUE=&{EMPTY} *** Test Cases *** @@ -57,14 +57,16 @@ A04 Resets the cache file when fetching and the cache file is not json File Should Exist ${file_cache_path} ${contents} = Get File ${file_cache_path} - Should Be Equal ${contents} ${EMPTY_CACHE} + Should Be Equal ${contents} {} [Teardown] Remove File ${file_cache_path} A05 Removes expired values from the cache file ${file_cache_path} = Set Variable robocache-A05.json - ${cache} = Create Dictionary expired_value=123 + ${cache} = Create Dictionary + ... retrieved_expired_value=123 + ... unretrieved_expired_value=456 Create Cache File With Content ${file_cache_path} ${cache} ... expiration=1970-01-01T00:00:00.000000 @@ -72,7 +74,8 @@ A05 Removes expired values from the cache file File Should Exist ${file_cache_path} ${contents} = Get File ${file_cache_path} - Should Be Equal ${contents} ${EMPTY_CACHE} + ${contents} = Evaluate json.loads('${contents}') modules=json + Dictionaries Should Be Equal ${contents} ${EMPTY_CACHE} [Teardown] Remove File ${file_cache_path} diff --git a/test/acceptance/test/A05.robot b/test/acceptance/test/A05.robot index cbafb8b..2ce4cc0 100644 --- a/test/acceptance/test/A05.robot +++ b/test/acceptance/test/A05.robot @@ -4,5 +4,5 @@ Library CacheLibrary robocache-A05.json *** Test Cases *** Fetches data from file - ${value} = Cache Retrieve Value expired_value + ${value} = Cache Retrieve Value retrieved_expired_value Should Be Equal ${value} ${None} diff --git a/test/integration/run-multi-value.robot b/test/integration/run-multi-value.robot index fb65603..62a87d0 100644 --- a/test/integration/run-multi-value.robot +++ b/test/integration/run-multi-value.robot @@ -4,25 +4,34 @@ Library Process Library FakerLibrary Library pabot.PabotLib Library CacheLibrary +Library ./secret.py # Clean up the data created by these tests Suite Setup Run Only Once Cache Reset *** Variables *** -${ITERATIONS} 50 +${ITERATIONS} 50 @{SUPPORTED_PRIMITIVES} -... str -... bool -... int -... float +... str +... bool +... int +... float @{COMPLEX_COLLECTION_TYPES} -... str -... bool -... int -... float -... list -... dict +... str +... bool +... int +... float +... list +... dict +... Secret +@{COMPLEX_COLLECTION_TYPES_VALUE_REMOVABLE} +... str +... bool +... int +... float +... list +... dict *** Test Cases *** @@ -59,7 +68,12 @@ Remove value by index ... set-data-${TEST_NAME} ... pick=first ... remove_value=False - Should Be Equal ${retrieved} ${value} + + IF "${value}" == "" + Should Be Equal As Secrets ${retrieved} ${value} + ELSE + Should Be Equal ${retrieved} ${value} + END Cache Remove Value From Collection set-data-${TEST_NAME} index=0 END @@ -71,7 +85,9 @@ Remove value by index Should Be Equal ${retrieved} ${None} Remove value by value - ${value_set} = Generate Complex Collection size=${ITERATIONS} + ${value_set} = Generate Complex Collection + ... size=${ITERATIONS} + ... types=${COMPLEX_COLLECTION_TYPES_VALUE_REMOVABLE} Cache Store Collection set-data-${TEST_NAME} @{value_set} @@ -193,7 +209,7 @@ Store and retrieve random float data Store and retrieve random dict data ${value_set} = Create List FOR ${i} IN RANGE ${ITERATIONS} - ${input} = FakerLibrary.Pydict value_types=${SUPPORTED_PRIMITIVES} + ${input} = FakerLibrary.Pydict Append To List ${value_set} ${input} END Cache Store Collection random-dicts @{value_set} @@ -206,7 +222,7 @@ Store and retrieve random dict data Store and retrieve random list data ${value_set} = Create List FOR ${i} IN RANGE ${ITERATIONS} - ${input} = FakerLibrary.Pylist value_types=${SUPPORTED_PRIMITIVES} + ${input} = FakerLibrary.Pylist Append To List ${value_set} ${input} END Cache Store Collection random-lists @{value_set} @@ -220,6 +236,12 @@ Store and retrieve random list data *** Keywords *** Generate Complex Collection [Arguments] ${size} ${types}=${COMPLEX_COLLECTION_TYPES} + ${supported} = Is Secret Supported + IF not ${supported} + Remove Values From List ${types} Secret + Log Not testing Secret. Not supported in current Robot version. level=WARN + END + ${collection} = Create List ${types} = FakerLibrary.Random Elements ${types} length=${size} @@ -236,6 +258,9 @@ Generate Complex Collection ${val} = FakerLibrary.Pylist value_types=${SUPPORTED_PRIMITIVES} ELSE IF '${type}' == 'dict' ${val} = FakerLibrary.Pydict value_types=${SUPPORTED_PRIMITIVES} + ELSE IF '${type}' == 'Secret' + ${val} = FakerLibrary.Pystr + ${val} = Convert To Secret ${val} ELSE Fail Unsupported type '${type}' END diff --git a/test/integration/run.robot b/test/integration/run.robot index 4f28d63..b8dabe8 100644 --- a/test/integration/run.robot +++ b/test/integration/run.robot @@ -3,6 +3,7 @@ Library Process Library FakerLibrary Library pabot.PabotLib Library CacheLibrary +Library ./secret.py # Clean up the random data created by these tests Suite Setup Run Only Once Cache Reset @@ -29,6 +30,21 @@ Store and retrieve random string data Should Be Equal ${retrieved} ${input} END +Store and retrieve random Secret string data + ${supported} = Is Secret Supported + Skip If not ${supported} msg=Secrets not supported in current Robot version. + + FOR ${i} IN RANGE ${ITERATIONS} + ${len} = FakerLibrary.Pyint min_value=1 max_value=100 + ${key} = FakerLibrary.Pystr min_chars=${len} max_chars=${len} + ${input} = FakerLibrary.Pystr min_chars=${len} max_chars=${len} + ${input} = Convert To Secret ${input} + Cache Store value ${key} ${input} + + ${retrieved} = Cache Retrieve value ${key} + Should Be Equal As Secrets ${retrieved} ${input} + END + Store and retrieve random int data FOR ${i} IN RANGE ${ITERATIONS} ${len} = FakerLibrary.Pyint min_value=1 max_value=100 diff --git a/test/integration/secret.py b/test/integration/secret.py new file mode 100644 index 0000000..3a8119a --- /dev/null +++ b/test/integration/secret.py @@ -0,0 +1,45 @@ +from dataclasses import dataclass + +from robot.api.deco import keyword + + +@dataclass +class FakeSecret: + """ + Fake Secret. + + Used only when running with a Robot version that does not support Secrets. + """ + + value: str = "" + + +try: + from robot.api.types import Secret # pyright: ignore[reportRedeclaration] +except ImportError: + # Before Robot 7.4 + Secret = FakeSecret + + +@keyword +def convert_to_secret(val: str): + """Convert a string to a secret""" + return Secret(val) + + +@keyword +def should_be_equal_as_secrets(first, second): # noqa: ANN001 + """ + Assert that 2 secrets contain the same value. + + No type annotations to prevent automatic type conversion. + """ + assert isinstance(first, Secret), "First is not a Secret" # noqa: S101 + assert isinstance(second, Secret), "Second is not a Secret" # noqa: S101 + assert first.value == second.value, "First does not equal Second" # noqa: S101 + + +@keyword +def is_secret_supported() -> bool: + """Return true when the current Robot version supports Secrets (>= 7.4)""" + return Secret.__name__ != "FakeSecret" diff --git a/uv.lock b/uv.lock index c61972d..51e6662 100644 --- a/uv.lock +++ b/uv.lock @@ -34,14 +34,14 @@ wheels = [ [[package]] name = "faker" -version = "40.8.0" +version = "40.11.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/70/03/14428edc541467c460d363f6e94bee9acc271f3e62470630fc9a647d0cf2/faker-40.8.0.tar.gz", hash = "sha256:936a3c9be6c004433f20aa4d99095df5dec82b8c7ad07459756041f8c1728875", size = 1956493, upload-time = "2026-03-04T16:18:48.161Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/e5/b16bf568a2f20fe7423282db4a4059dbcadef70e9029c1c106836f8edd84/faker-40.11.1.tar.gz", hash = "sha256:61965046e79e8cfde4337d243eac04c0d31481a7c010033141103b43f603100c", size = 1957415, upload-time = "2026-03-23T14:05:50.233Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4c/3b/c6348f1e285e75b069085b18110a4e6325b763a5d35d5e204356fc7c20b3/faker-40.8.0-py3-none-any.whl", hash = "sha256:eb21bdba18f7a8375382eb94fb436fce07046893dc94cb20817d28deb0c3d579", size = 1989124, upload-time = "2026-03-04T16:18:46.45Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ec/3c4b78eb0d2f6a81fb8cc9286745845bff661e6815741eff7a6ac5fcc9ea/faker-40.11.1-py3-none-any.whl", hash = "sha256:3af3a213ba8fb33ce6ba2af7aef2ac91363dae35d0cec0b2b0337d189e5bee2a", size = 1989484, upload-time = "2026-03-23T14:05:48.793Z" }, ] [[package]] @@ -65,6 +65,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jsonpickle" +version = "4.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/a6/d07afcfdef402900229bcca795f80506b207af13a838d4d99ad45abf530c/jsonpickle-4.1.1.tar.gz", hash = "sha256:f86e18f13e2b96c1c1eede0b7b90095bbb61d99fedc14813c44dc2f361dbbae1", size = 316885, upload-time = "2025-06-02T20:36:11.57Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/73/04df8a6fa66d43a9fd45c30f283cc4afff17da671886e451d52af60bdc7e/jsonpickle-4.1.1-py3-none-any.whl", hash = "sha256:bb141da6057898aa2438ff268362b126826c812a1721e31cf08a6e142910dc91", size = 47125, upload-time = "2025-06-02T20:36:08.647Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -252,11 +261,11 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] @@ -295,6 +304,7 @@ name = "robotframework-cache" version = "1.2.0" source = { editable = "." } dependencies = [ + { name = "jsonpickle" }, { name = "robotframework" }, { name = "robotframework-pabot" }, ] @@ -310,6 +320,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "jsonpickle", specifier = ">=4.1.1" }, { name = "robotframework", specifier = ">=5.0.1" }, { name = "robotframework-pabot", specifier = ">=2.2.0" }, ]