From dc61fed978b5bee9deb95c13ebe9704a94c3df12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Fri, 4 Jul 2025 17:38:31 +0300 Subject: [PATCH 01/39] Back to dev version --- setup.py | 2 +- src/robot/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 946654fa060..ab3de7a6d9f 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ # Version number typically updated by running `invoke set-version `. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = "7.3.2" +VERSION = "7.3.3.dev1" with open(join(dirname(abspath(__file__)), "README.rst")) as f: LONG_DESCRIPTION = f.read() base_url = "https://github.com/robotframework/robotframework/blob/master" diff --git a/src/robot/version.py b/src/robot/version.py index d5005bfcec7..90a9ecc42f5 100644 --- a/src/robot/version.py +++ b/src/robot/version.py @@ -18,7 +18,7 @@ # Version number typically updated by running `invoke set-version `. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = "7.3.2" +VERSION = "7.3.3.dev1" def get_version(naked=False): From f0f09ab48e664715585554c2a12242346b06a5a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Mon, 25 Aug 2025 15:03:22 +0300 Subject: [PATCH 02/39] Let's start RF 7.4 development --- setup.py | 2 +- src/robot/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index ab3de7a6d9f..33e40bf0c21 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ # Version number typically updated by running `invoke set-version `. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = "7.3.3.dev1" +VERSION = "7.4.dev1" with open(join(dirname(abspath(__file__)), "README.rst")) as f: LONG_DESCRIPTION = f.read() base_url = "https://github.com/robotframework/robotframework/blob/master" diff --git a/src/robot/version.py b/src/robot/version.py index 90a9ecc42f5..ad147d6b6d0 100644 --- a/src/robot/version.py +++ b/src/robot/version.py @@ -18,7 +18,7 @@ # Version number typically updated by running `invoke set-version `. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = "7.3.3.dev1" +VERSION = "7.4.dev1" def get_version(naked=False): From 3fdd8dd79a04bb1f7af171e8b8a74edb4b60ede1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Mon, 25 Aug 2025 15:06:11 +0300 Subject: [PATCH 03/39] Cleanup. - Move some helper methods to the base class. - Use `logger` instead of `self.log`. - Fix language issues in documentation. The main motivation was to make the number of warnings shown by IDEs smaller. A small functional change is that messages logged by various keywords aren't anymore NFC normalized. That's done by `self.log` (it calles `safe_str` by default) but `logger` doesn't do that. --- .../builtin/should_be_equal.robot | 5 +- src/robot/libraries/BuiltIn.py | 215 +++++++++--------- 2 files changed, 107 insertions(+), 113 deletions(-) diff --git a/atest/robot/standard_libraries/builtin/should_be_equal.robot b/atest/robot/standard_libraries/builtin/should_be_equal.robot index 9469e0caf25..73c6466b778 100644 --- a/atest/robot/standard_libraries/builtin/should_be_equal.robot +++ b/atest/robot/standard_libraries/builtin/should_be_equal.robot @@ -66,8 +66,9 @@ formatter=repr with multiline and different line endings formatter=repr/ascii with multiline and non-ASCII characters ${tc} = Check test case ${TESTNAME} - Check Log Message ${tc[0, 1]} Å\nÄ\n\Ö\n\n!=\n\nÅ\nÄ\n\Ö - Check Log Message ${tc[1, 1]} Å\nÄ\n\Ö\n\n!=\n\nÅ\nÄ\n\Ö + Check Log Message ${tc[0, 1]} Å\nÄ\n\Ö\n\n!=\n\nÅ\nA\u0308\n\Ö + Check Log Message ${tc[1, 1]} Å\nÄ\n\Ö\n\n!=\n\nÅ\nA\u0308\n\Ö + Check Log Message ${tc[2, 1]} Å\nÄ\n\Ö\n\n!=\n\nÅ\nA\u0308\n\Ö Invalid formatter Check test case ${TESTNAME} diff --git a/src/robot/libraries/BuiltIn.py b/src/robot/libraries/BuiltIn.py index 18d09af57d8..30e9546dceb 100644 --- a/src/robot/libraries/BuiltIn.py +++ b/src/robot/libraries/BuiltIn.py @@ -115,11 +115,54 @@ def _log_types(self, *args): def _log_types_at_level(self, level, *args): msg = ["Argument types are:"] + [self._get_type(a) for a in args] - self.log("\n".join(msg), level) + logger.write("\n".join(msg), level) def _get_type(self, arg): return str(type(arg)) + def _convert_to_integer(self, orig, base=None): + try: + item, base = self._get_base(orig, base) + if base: + return int(item, self._convert_to_integer(base)) + return int(item) + except Exception: + raise RuntimeError( + f"'{orig}' cannot be converted to an integer: {get_error_message()}" + ) + + def _get_base(self, item, base): + if not isinstance(item, str): + return item, base + item = normalize(item) + if item.startswith(("-", "+")): + sign = item[0] + item = item[1:] + else: + sign = "" + bases = {"0b": 2, "0o": 8, "0x": 16} + if base or not item.startswith(tuple(bases)): + return sign + item, base + return sign + item[2:], bases[item[:2]] + + def _convert_to_number(self, item, precision=None): + number = self._convert_to_number_without_precision(item) + if precision is not None: + number = float(round(number, self._convert_to_integer(precision))) + return number + + def _convert_to_number_without_precision(self, item): + try: + return float(item) + except (ValueError, TypeError): + error = get_error_message() + try: + return float(self._convert_to_integer(item)) + except RuntimeError: + raise RuntimeError( + f"'{item}' cannot be converted to a floating point number: {error}" + ) + class _Converter(_BuiltInBase): @@ -152,31 +195,6 @@ def convert_to_integer(self, item, base=None): self._log_types(item) return self._convert_to_integer(item, base) - def _convert_to_integer(self, orig, base=None): - try: - item, base = self._get_base(orig, base) - if base: - return int(item, self._convert_to_integer(base)) - return int(item) - except Exception: - raise RuntimeError( - f"'{orig}' cannot be converted to an integer: {get_error_message()}" - ) - - def _get_base(self, item, base): - if not isinstance(item, str): - return item, base - item = normalize(item) - if item.startswith(("-", "+")): - sign = item[0] - item = item[1:] - else: - sign = "" - bases = {"0b": 2, "0o": 8, "0x": 16} - if base or not item.startswith(tuple(bases)): - return sign + item, base - return sign + item[2:], bases[item[:2]] - def convert_to_binary(self, item, base=None, prefix=None, length=None): """Converts the given item to a binary string. @@ -241,7 +259,7 @@ def convert_to_hex( possible minus sign). If the value is initially shorter than the required length, it is padded with zeros. - By default the value is returned as an upper case string, but the + The value is returned as an upper case string by default, but the ``lowercase`` argument a true value (see `Boolean arguments`) turns the value (but not the given prefix) to lower case. @@ -299,24 +317,6 @@ def convert_to_number(self, item, precision=None): self._log_types(item) return self._convert_to_number(item, precision) - def _convert_to_number(self, item, precision=None): - number = self._convert_to_number_without_precision(item) - if precision is not None: - number = float(round(number, self._convert_to_integer(precision))) - return number - - def _convert_to_number_without_precision(self, item): - try: - return float(item) - except (ValueError, TypeError): - error = get_error_message() - try: - return float(self._convert_to_integer(item)) - except RuntimeError: - raise RuntimeError( - f"'{item}' cannot be converted to a floating point number: {error}" - ) - def convert_to_string(self, item): """Converts the given item to a Unicode string. @@ -373,7 +373,7 @@ def convert_to_bytes(self, input, input_type="text"): In addition to giving the input as a string, it is possible to use lists or other iterables containing individual characters or numbers. - In that case numbers do not need to be padded to certain length and + In that case numbers do not need to be padded to certain length, and they cannot contain extra spaces. Examples (last column shows returned bytes): @@ -714,7 +714,7 @@ def _raise_multi_diff(self, first, second, msg, formatter): second_lines = second.splitlines(keepends=True) if len(first_lines) < 3 or len(second_lines) < 3: return - self.log(f"{first.rstrip()}\n\n!=\n\n{second.rstrip()}") + logger.info(f"{first.rstrip()}\n\n!=\n\n{second.rstrip()}") diffs = list( difflib.unified_diff( first_lines, @@ -1497,7 +1497,7 @@ def get_count(self, container, item): f"Converting '{container}' to list failed: {get_error_message()}" ) count = container.count(item) - self.log(f"Item found from container {count} time{s(count)}.") + logger.info(f"Item found from container {count} time{s(count)}.") return count def should_not_match( @@ -1633,7 +1633,7 @@ def get_length(self, item): Empty`. """ length = self._get_length(item) - self.log(f"Length is {length}.") + logger.info(f"Length is {length}.") return length def _get_length(self, item): @@ -1715,10 +1715,9 @@ def get_variables(self, no_decoration=False): returned dictionary has no effect on the variables available in the current scope. - By default variables are returned with ``${}``, ``@{}`` or ``&{}`` - decoration based on variable types. Giving a true value (see `Boolean - arguments`) to the optional argument ``no_decoration`` will return - the variables without the decoration. + Variables are returned with ``${}``, ``@{}`` or ``&{}`` decoration based + on variable types by default. Giving a true value (see `Boolean arguments`) + to the ``no_decoration`` argument allows getting variables without decoration. Example: | ${example_variable} = | Set Variable | example value | @@ -1767,7 +1766,7 @@ def log_variables(self, level="INFO"): for name in sorted(variables, key=lambda s: s[2:-1].casefold()): name, value = self._get_logged_variable(name, variables) msg = format_assign_message(name, value, cut_long=False) - self.log(msg, level) + logger.write(msg, level) def _get_logged_variable(self, name, variables): value = variables[name] @@ -1842,7 +1841,7 @@ def replace_variables(self, text): If the text contains undefined variables, this keyword fails. If the given ``text`` contains only a single variable, its value is - returned as-is and it can be any object. Otherwise, this keyword + returned as-is, and it can be any object. Otherwise, this keyword always returns a string. Example: @@ -1860,7 +1859,7 @@ def set_variable(self, *values): """Returns the given values which can then be assigned to a variables. This keyword is mainly used for setting scalar variables. - Additionally it can be used for converting a scalar variable + Additionally, it can be used for converting a scalar variable containing a list to a list variable or to multiple scalar variables. It is recommended to use `Create List` when creating new lists. @@ -2056,7 +2055,7 @@ def set_global_variable(self, name, *values): Variables set with this keyword are globally available in all subsequent test suites, test cases and user keywords. Also variables - created Variables sections are overridden. Variables assigned locally + created in the Variables sections are overridden. Variables assigned locally based on keyword return values or by using `Set Suite Variable`, `Set Test Variable` or `Set Local Variable` override these variables in that scope, but the global value is not changed in those cases. @@ -2135,12 +2134,6 @@ def _log_set_variable(self, name, value): class _RunKeyword(_BuiltInBase): - # If you use any of these run keyword variants from another library, you - # should register those keywords with 'register_run_keyword' method. See - # the documentation of that method at the end of this file. There are also - # other run keyword variant keywords in BuiltIn which can also be seen - # at the end of this file. - @run_keyword_variant(resolve=0, dry_run=True) def run_keyword(self, name, *args): """Executes the given keyword with the given arguments. @@ -2216,7 +2209,7 @@ def run_keywords(self, *keywords): to take care of multiple actions and creating a new higher level user keyword would be an overkill. - By default all arguments are expected to be keywords to be executed. + By default, all arguments are expected to be keywords to be executed. Examples: | `Run Keywords` | `Initialize database` | `Start servers` | `Clear logs` | @@ -2378,8 +2371,9 @@ def run_keyword_unless(self, condition, name, *args): See `Run Keyword If` for more information and an example. Notice that this keyword does not support ELSE or ELSE IF branches like `Run Keyword If` does. """ - if not self._is_true(condition): - return self.run_keyword(name, *args) + if self._is_true(condition): + return None + return self.run_keyword(name, *args) @run_keyword_variant(resolve=0, dry_run=True) def run_keyword_and_ignore_error(self, name, *args): @@ -2395,7 +2389,7 @@ def run_keyword_and_ignore_error(self, name, *args): `Run Keyword If` for a usage example. Errors caused by invalid syntax, timeouts, or fatal exceptions are not - caught by this keyword. Otherwise this keyword itself never fails. + caught by this keyword, but otherwise this keyword never fails. *NOTE:* Robot Framework 5.0 introduced native TRY/EXCEPT functionality that is generally recommended for error handling. @@ -2417,7 +2411,7 @@ def run_keyword_and_warn_on_failure(self, name, *args): like `Run Keyword And Ignore Error` does. Errors caused by invalid syntax, timeouts, or fatal exceptions are not - caught by this keyword. Otherwise this keyword itself never fails. + caught by this keyword, but otherwise this keyword never fails. New in Robot Framework 4.0. """ @@ -2442,7 +2436,7 @@ def run_keyword_and_return_status(self, name, *args): | `Run Keyword If` | ${passed} | Another keyword | Errors caused by invalid syntax, timeouts, or fatal exceptions are not - caught by this keyword. Otherwise this keyword itself never fails. + caught by this keyword, but otherwise this keyword never fails. """ status, _ = self.run_keyword_and_ignore_error(name, *args) return status == "PASS" @@ -2475,9 +2469,9 @@ def run_keyword_and_expect_error(self, expected_error, name, *args): and ``*args`` exactly like with `Run Keyword`. The expected error must be given in the same format as in Robot Framework - reports. By default it is interpreted as a glob pattern with ``*``, ``?`` - and ``[chars]`` as wildcards, but that can be changed by using various - prefixes explained in the table below. Prefixes are case-sensitive and + reports. It is interpreted as a glob pattern with ``*``, ``?`` and ``[chars]`` + as wildcards by default, but that can be changed by using various + prefixes explained in the table below. Prefixes are case-sensitive, and they must be separated from the actual message with a colon and an optional space like ``PREFIX: Message`` or ``PREFIX:Message``. @@ -2490,7 +2484,7 @@ def run_keyword_and_expect_error(self, expected_error, name, *args): See the `Pattern matching` section for more information about glob patterns and regular expressions. - If the expected error occurs, the error message is returned and it can + If the expected error occurs, the error message is returned, and it can be further processed or tested if needed. If there is no error, or the error does not match the expected error, this keyword fails. @@ -2509,7 +2503,7 @@ def run_keyword_and_expect_error(self, expected_error, name, *args): *NOTE:* Regular expression matching used to require only the beginning of the error to match the given pattern. That was changed in Robot - Framework 5.0 and nowadays the pattern must match the error fully. + Framework 5.0 and, nowadays, the pattern must match the error fully. To match only the beginning, add ``.*`` at the end of the pattern like ``REGEXP: Start.*``. @@ -2607,20 +2601,20 @@ def _get_repeat_timeout(self, timestr): def _keywords_repeated_by_count(self, count, name, args): if count <= 0: - self.log(f"Keyword '{name}' repeated zero times.") + logger.info(f"Keyword '{name}' repeated zero times.") for i in range(count): - self.log(f"Repeating keyword, round {i + 1}/{count}.") + logger.info(f"Repeating keyword, round {i + 1}/{count}.") yield name, args def _keywords_repeated_by_timeout(self, timeout, name, args): if timeout <= 0: - self.log(f"Keyword '{name}' repeated zero times.") + logger.info(f"Keyword '{name}' repeated zero times.") round = 0 maxtime = time.time() + timeout while time.time() < maxtime: round += 1 remaining = secs_to_timestr(maxtime - time.time(), compact=True) - self.log(f"Repeating keyword, round {round}, {remaining} remaining.") + logger.info(f"Repeating keyword, round {round}, {remaining} remaining.") yield name, args @run_keyword_variant(resolve=2, dry_run=True) @@ -2753,7 +2747,7 @@ def set_variable_if(self, condition, *values): conditions without a limit. | ${var} = | Set Variable If | ${rc} == 0 | zero | - | ... | ${rc} > 0 | greater than zero | less then zero | + | ... | ${rc} > 0 | greater than zero | less than zero | | | | ${var} = | Set Variable If | | ... | ${rc} == 0 | zero | @@ -2922,7 +2916,7 @@ def continue_for_loop(self): """ if not self._context.allow_loop_control: raise DataError("'Continue For Loop' can only be used inside a loop.") - self.log("Continuing for loop from the next iteration.") + logger.info("Continuing for loop from the next iteration.") raise ContinueLoop def continue_for_loop_if(self, condition): @@ -2989,7 +2983,7 @@ def exit_for_loop(self): """ if not self._context.allow_loop_control: raise DataError("'Exit For Loop' can only be used inside a loop.") - self.log("Exiting for loop altogether.") + logger.info("Exiting for loop altogether.") raise BreakLoop def exit_for_loop_if(self, condition): @@ -3065,7 +3059,7 @@ def return_from_keyword(self, *return_values): | Example | ${index} = Find Index baz @{LIST} | Should Be Equal ${index} ${1} - | ${index} = Find Index non existing @{LIST} + | ${index} = Find Index non-existing @{LIST} | Should Be Equal ${index} ${-1} | | ***** Keywords ***** @@ -3085,7 +3079,7 @@ def return_from_keyword(self, *return_values): self._return_from_keyword(return_values) def _return_from_keyword(self, return_values=None, failures=None): - self.log("Returning from the enclosing user keyword.") + logger.info("Returning from the enclosing user keyword.") raise ReturnFromKeyword(return_values, failures) @run_keyword_variant(resolve=1) @@ -3143,8 +3137,8 @@ def run_keyword_and_return(self, name, *args): | ${result} = | `My Keyword` | arg1 | arg2 | | `Return From Keyword` | ${result} | | | - Use `Run Keyword And Return If` if you want to run keyword and return - based on a condition. + If you want to run a keyword and return based on a condition, use + `Run Keyword And Return If`. """ try: ret = self.run_keyword(name, *args) @@ -3166,8 +3160,8 @@ def run_keyword_and_return_if(self, condition, name, *args): | # Above is equivalent to: | | `Run Keyword If` | ${rc} > 0 | `Run Keyword And Return` | `My Keyword ` | arg1 | arg2 | - Use `Return From Keyword If` if you want to return a certain value - based on a condition. + If you want to return a certain value based on a condition, use + `Return From Keyword If` """ if self._is_true(condition): self.run_keyword_and_return(name, *args) @@ -3189,7 +3183,7 @@ def pass_execution(self, message, *tags): failures in executed teardowns, will fail the execution. It is mandatory to give a message explaining why execution was passed. - By default the message is considered plain text, but starting it with + The message is considered plain text by default, but starting it with ``*HTML*`` allows using HTML formatting. It is also possible to modify test tags passing tags after the message @@ -3220,7 +3214,7 @@ def pass_execution(self, message, *tags): raise RuntimeError("Message cannot be empty.") self._set_and_remove_tags(tags) log_message, level = self._get_logged_test_message_and_level(message) - self.log(f"Execution passed with message:\n{log_message}", level) + logger.write(f"Execution passed with message:\n{log_message}", level) raise PassExecution(message) @run_keyword_variant(resolve=1) @@ -3271,9 +3265,9 @@ def sleep(self, time_, reason=None): if seconds < 0: seconds = 0 self._sleep_in_parts(seconds) - self.log(f"Slept {secs_to_timestr(seconds)}.") + logger.info(f"Slept {secs_to_timestr(seconds)}.") if reason: - self.log(reason) + logger.info(reason) def _sleep_in_parts(self, seconds): # time.sleep can't be stopped in windows @@ -3420,7 +3414,7 @@ def log_many(self, *messages): log levels, use HTML, or log to the console. """ for msg in self._yield_logged_messages(messages): - self.log(msg) + logger.info(msg) def _yield_logged_messages(self, messages): for msg in messages: @@ -3437,19 +3431,18 @@ def _yield_logged_messages(self, messages): def log_to_console(self, message, stream="STDOUT", no_newline=False, format=""): """Logs the given message to the console. - By default uses the standard output stream. Using the standard error + Uses the standard output stream by default. Using the standard error stream is possible by giving the ``stream`` argument value ``STDERR`` (case-insensitive). - By default appends a newline to the logged message. This can be + Appends a newline to the logged message by default. This can be disabled by giving the ``no_newline`` argument a true value (see `Boolean arguments`). - By default adds no alignment formatting. The ``format`` argument allows, - for example, alignment and customized padding of the log message. Please see the - [https://docs.python.org/3/library/string.html#formatspec|format specification] for - detailed alignment possibilities. This argument is new in Robot - Framework 5.0. + It is possible to add alignment and padding using the ``format`` argument. + See the + [https://docs.python.org/3/library/string.html#formatspec|format specification] + for more details about the syntax. This argument is new in Robot Framework 5.0. Examples: | Log To Console | Hello, console! | | @@ -3493,7 +3486,7 @@ def set_log_level(self, level): """ old = self._context.output.set_log_level(level) self._namespace.variables.set_global("${LOG_LEVEL}", level.upper()) - self.log(f"Log level changed from {old} to {level.upper()}.", level="DEBUG") + logger.debug(f"Log level changed from {old} to {level.upper()}.") return old def reset_log_level(self): @@ -3519,7 +3512,7 @@ def reload_library(self, name_or_instance): calls this keyword as a method. """ lib = self._namespace.reload_library(name_or_instance) - self.log(f"Reloaded library {lib.name} with {len(lib.keywords)} keywords.") + logger.info(f"Reloaded library {lib.name} with {len(lib.keywords)} keywords.") @run_keyword_variant(resolve=0) def import_library(self, name, *args): @@ -3689,7 +3682,7 @@ def get_time(self, format="timestamp", time_="NOW"): 3) Otherwise (and by default) the time is returned as a timestamp string in the format ``2006-02-24 15:08:31``. - By default this keyword returns the current local time, but + Returns the current local time by default, but that can be altered using ``time`` argument as explained below. Note that all checks involving strings are case-insensitive. @@ -3772,7 +3765,7 @@ def evaluate(self, expression, modules=None, namespace=None): be explicitly specified using the ``modules`` argument: - When nested modules like ``rootmod.submod`` are implemented so that - the root module does not automatically import sub modules. This is + the root module does not automatically import submodules. This is illustrated by the ``selenium.webdriver`` example below. - When using a module in the expression part of a list comprehension. @@ -3898,7 +3891,7 @@ def set_test_message(self, message, append=False, separator=" "): if self._context.in_test_teardown: self._variables.set_test("${TEST_MESSAGE}", test.message) message, level = self._get_logged_test_message_and_level(test.message) - self.log(f"Set test message to:\n{message}", level) + logger.write(f"Set test message to:\n{message}", level) def _get_new_text(self, old, new, append, handle_html=False, separator=" "): if not isinstance(new, str): @@ -3946,7 +3939,7 @@ def set_test_documentation(self, doc, append=False, separator=" "): ) test.doc = self._get_new_text(test.doc, doc, append, separator=separator) self._variables.set_test("${TEST_DOCUMENTATION}", test.doc) - self.log(f"Set test documentation to:\n{test.doc}") + logger.info(f"Set test documentation to:\n{test.doc}") def set_suite_documentation(self, doc, append=False, top=False, separator=" "): """Sets documentation for the current test suite. @@ -3972,7 +3965,7 @@ def set_suite_documentation(self, doc, append=False, top=False, separator=" "): suite = self._get_context(top).suite suite.doc = self._get_new_text(suite.doc, doc, append, separator=separator) self._variables.set_suite("${SUITE_DOCUMENTATION}", suite.doc, top) - self.log(f"Set suite documentation to:\n{suite.doc}") + logger.info(f"Set suite documentation to:\n{suite.doc}") def set_suite_metadata(self, name, value, append=False, top=False, separator=" "): """Sets metadata for the current test suite. @@ -4003,7 +3996,7 @@ def set_suite_metadata(self, name, value, append=False, top=False, separator=" " original, value, append, separator=separator ) self._variables.set_suite("${SUITE_METADATA}", metadata.copy(), top) - self.log(f"Set suite metadata '{name}' to value '{metadata[name]}'.") + logger.info(f"Set suite metadata '{name}' to value '{metadata[name]}'.") def set_tags(self, *tags): """Adds given ``tags`` for the current test or all tests in a suite. @@ -4028,7 +4021,7 @@ def set_tags(self, *tags): ctx.suite.set_tags(tags, persist=True) else: raise RuntimeError("'Set Tags' cannot be used in suite teardown.") - self.log(f"Set tag{s(tags)} {seq2str(tags)}.") + logger.info(f"Set tag{s(tags)} {seq2str(tags)}.") def remove_tags(self, *tags): """Removes given ``tags`` from the current test or all tests in a suite. @@ -4056,7 +4049,7 @@ def remove_tags(self, *tags): ctx.suite.set_tags(remove=tags, persist=True) else: raise RuntimeError("'Remove Tags' cannot be used in suite teardown.") - self.log(f"Removed tag{s(tags)} {seq2str(tags)}.") + logger.info(f"Removed tag{s(tags)} {seq2str(tags)}.") def get_library_instance(self, name=None, all=False): """Returns the currently active instance of the specified library. @@ -4068,8 +4061,8 @@ def get_library_instance(self, name=None, all=False): | from robot.libraries.BuiltIn import BuiltIn | | def title_should_start_with(expected): - | seleniumlib = BuiltIn().get_library_instance('SeleniumLibrary') - | title = seleniumlib.get_title() + | lib = BuiltIn().get_library_instance('SeleniumLibrary') + | title = lib.get_title() | if not title.startswith(expected): | raise AssertionError(f"Title '{title}' did not start with '{expected}'.") From 02d65889091285bfa3044fa6c2eb3545e6ef3242 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Wed, 27 Aug 2025 10:14:31 +0300 Subject: [PATCH 04/39] Libdoc: Exclude tags from priva kws in HTML output These keywords were excluded already earlier, their tags should be excluded as well. Fixes #5490. --- atest/robot/libdoc/html_output.robot | 3 +++ atest/robot/libdoc/json_output.robot | 2 +- atest/robot/libdoc/resource_file.robot | 3 ++- atest/testdata/libdoc/resource.robot | 2 +- src/robot/libdocpkg/model.py | 11 +++++++++-- 5 files changed, 16 insertions(+), 5 deletions(-) diff --git a/atest/robot/libdoc/html_output.robot b/atest/robot/libdoc/html_output.robot index d259c49bc7d..2dbfdb7aa9d 100644 --- a/atest/robot/libdoc/html_output.robot +++ b/atest/robot/libdoc/html_output.robot @@ -122,6 +122,9 @@ Private keyword should be excluded Should Not Be Equal ${keyword}[name] Private END +All tags does not include tags from private keywords + ${MODEL}[tags] ['\${3}', '?!?!??', 'a', 'b', 'bar', 'dar', 'foo', 'Has', 'kw4', 'tags'] + *** Keywords *** Verify Argument Models [Arguments] ${arg_models} @{expected_reprs} diff --git a/atest/robot/libdoc/json_output.robot b/atest/robot/libdoc/json_output.robot index deec2eb1cf4..9516656c135 100644 --- a/atest/robot/libdoc/json_output.robot +++ b/atest/robot/libdoc/json_output.robot @@ -119,7 +119,7 @@ User keyword documentation formatting Private user keyword should be included [Setup] Run Libdoc And Parse Model From JSON ${TESTDATADIR}/resource.robot ${MODEL}[keywords][-1][name] Private - ${MODEL}[keywords][-1][tags] ['robot:private'] + ${MODEL}[keywords][-1][tags] ['robot:private', 'tag-in-private', 'tags'] ${MODEL}[keywords][-1][private] True ${MODEL['keywords'][0].get('private')} None diff --git a/atest/robot/libdoc/resource_file.robot b/atest/robot/libdoc/resource_file.robot index 0e138bdec20..f989459ddc7 100644 --- a/atest/robot/libdoc/resource_file.robot +++ b/atest/robot/libdoc/resource_file.robot @@ -43,7 +43,8 @@ Spec version Resource Tags Specfile Tags Should Be \${3} ?!?!?? a b bar dar - ... foo Has kw4 robot:private tags + ... foo Has kw4 robot:private + ... tag-in-private tags Resource Has No Inits Should Have No Init diff --git a/atest/testdata/libdoc/resource.robot b/atest/testdata/libdoc/resource.robot index 90da1af5d15..0fb459ee9cc 100644 --- a/atest/testdata/libdoc/resource.robot +++ b/atest/testdata/libdoc/resource.robot @@ -80,5 +80,5 @@ Deprecation No Operation Private - [Tags] robot:private + [Tags] robot:private tags tag-in-private No Operation diff --git a/src/robot/libdocpkg/model.py b/src/robot/libdocpkg/model.py index 92fd04285aa..076f2c908bc 100644 --- a/src/robot/libdocpkg/model.py +++ b/src/robot/libdocpkg/model.py @@ -97,7 +97,14 @@ def _process_keywords(self, kws): @property def all_tags(self): - return Tags(chain.from_iterable(kw.tags for kw in self.keywords)) + return self._get_tags() + + def _get_tags(self, include_private=True): + if include_private: + keywords = self.keywords + else: + keywords = (kw for kw in self.keywords if not kw.private) + return Tags(chain.from_iterable(kw.tags for kw in keywords)) def save(self, output=None, format="HTML", theme=None, lang=None): with LibdocOutput(output, format) as outfile: @@ -138,7 +145,7 @@ def to_dictionary(self, include_private=False, theme=None, lang=None): "docFormat": self.doc_format, "source": str(self.source) if self.source else None, "lineno": self.lineno, - "tags": list(self.all_tags), + "tags": list(self._get_tags(include_private)), "inits": [init.to_dictionary() for init in self.inits], "keywords": [ kw.to_dictionary() From b9a2aff677bda4ddfc2ca009d49cd0d6d6e61579 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 27 Aug 2025 12:05:53 +0300 Subject: [PATCH 05/39] Bump actions/checkout from 4 to 5 (#5483) Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/acceptance_tests_cpython.yml | 2 +- .github/workflows/acceptance_tests_cpython_pr.yml | 2 +- .github/workflows/unit_tests.yml | 2 +- .github/workflows/unit_tests_pr.yml | 2 +- .github/workflows/web_tests.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/acceptance_tests_cpython.yml b/.github/workflows/acceptance_tests_cpython.yml index 7e4a25739a0..36f141b5540 100644 --- a/.github/workflows/acceptance_tests_cpython.yml +++ b/.github/workflows/acceptance_tests_cpython.yml @@ -33,7 +33,7 @@ jobs: name: Python ${{ matrix.python-version }} on ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup python for starting the tests uses: actions/setup-python@v5.6.0 diff --git a/.github/workflows/acceptance_tests_cpython_pr.yml b/.github/workflows/acceptance_tests_cpython_pr.yml index 1b49dc448fe..3a83d47b047 100644 --- a/.github/workflows/acceptance_tests_cpython_pr.yml +++ b/.github/workflows/acceptance_tests_cpython_pr.yml @@ -26,7 +26,7 @@ jobs: name: Python ${{ matrix.python-version }} on ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup python for starting the tests uses: actions/setup-python@v5.6.0 diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index c52d155900d..8165f4cbc93 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -29,7 +29,7 @@ jobs: name: Python ${{ matrix.python-version }} on ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup python ${{ matrix.python-version }} uses: actions/setup-python@v5.6.0 diff --git a/.github/workflows/unit_tests_pr.yml b/.github/workflows/unit_tests_pr.yml index 91eb380d330..8f648d27f20 100644 --- a/.github/workflows/unit_tests_pr.yml +++ b/.github/workflows/unit_tests_pr.yml @@ -21,7 +21,7 @@ jobs: name: Python ${{ matrix.python-version }} on ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup python ${{ matrix.python-version }} uses: actions/setup-python@v5.6.0 diff --git a/.github/workflows/web_tests.yml b/.github/workflows/web_tests.yml index 8e7cc6f03c9..69d4606fb68 100644 --- a/.github/workflows/web_tests.yml +++ b/.github/workflows/web_tests.yml @@ -19,7 +19,7 @@ jobs: name: Jest tests for the web components steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup Node uses: actions/setup-node@v4 From 4a4a394cc585107efa56e8180a5b6b2c827cfee5 Mon Sep 17 00:00:00 2001 From: Canlin Guo <105424474+Fix3dP0int@users.noreply.github.com> Date: Wed, 27 Aug 2025 17:07:59 +0800 Subject: [PATCH 06/39] extract untrusted github.head_ref and github.ref_name inputs to environment variables (#5479) Signed-off-by: fixedpoint <961750412@qq.com> --- .github/workflows/acceptance_tests_cpython.yml | 3 ++- .github/workflows/acceptance_tests_cpython_pr.yml | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/acceptance_tests_cpython.yml b/.github/workflows/acceptance_tests_cpython.yml index 36f141b5540..9159e39b794 100644 --- a/.github/workflows/acceptance_tests_cpython.yml +++ b/.github/workflows/acceptance_tests_cpython.yml @@ -94,6 +94,7 @@ jobs: if: failure() env: RFLOGS_API_KEY: ${{ secrets.RFLOGS_API_KEY }} + BRANCH_NAME: ${{ github.head_ref || github.ref_name }} working-directory: atest/results shell: python run: | @@ -116,7 +117,7 @@ jobs: "--tag", f"workflow:${{ github.workflow }}", "--tag", f"os:${{ runner.os }}", "--tag", f"python-version:${{ matrix.python-version }}", - "--tag", f"branch:${{ github.head_ref || github.ref_name }}", + "--tag", f"branch:{os.environ['BRANCH_NAME']}", result_dir ] diff --git a/.github/workflows/acceptance_tests_cpython_pr.yml b/.github/workflows/acceptance_tests_cpython_pr.yml index 3a83d47b047..8f8cefd82b3 100644 --- a/.github/workflows/acceptance_tests_cpython_pr.yml +++ b/.github/workflows/acceptance_tests_cpython_pr.yml @@ -81,6 +81,7 @@ jobs: if: failure() env: RFLOGS_API_KEY: ${{ secrets.RFLOGS_API_KEY }} + BRANCH_NAME: ${{ github.head_ref || github.ref_name }} working-directory: atest/results shell: python run: | @@ -103,7 +104,7 @@ jobs: "--tag", f"workflow:${{ github.workflow }}", "--tag", f"os:${{ runner.os }}", "--tag", f"python-version:${{ matrix.python-version }}", - "--tag", f"branch:${{ github.head_ref || github.ref_name }}", + "--tag", f"branch:{os.environ['BRANCH_NAME']}", result_dir ] From 1494bdc8216a55a30518f1fd9c58bfdf08d2167b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Wed, 27 Aug 2025 14:59:03 +0300 Subject: [PATCH 07/39] BuiltIn: Explicitly mark positional-only arguments Fixes #5025. --- src/robot/libraries/BuiltIn.py | 69 ++++++++++++++++------------------ 1 file changed, 33 insertions(+), 36 deletions(-) diff --git a/src/robot/libraries/BuiltIn.py b/src/robot/libraries/BuiltIn.py index 30e9546dceb..866cf4fbb9a 100644 --- a/src/robot/libraries/BuiltIn.py +++ b/src/robot/libraries/BuiltIn.py @@ -1733,7 +1733,7 @@ def get_variables(self, no_decoration=False): @keyword(types=None) @run_keyword_variant(resolve=0) - def get_variable_value(self, name, default=None): + def get_variable_value(self, name, default=None, /): r"""Returns variable value or ``default`` if the variable does not exist. The name of the variable can be given either as a normal variable name @@ -1742,8 +1742,7 @@ def get_variable_value(self, name, default=None): or accessing variables` section, using the escaped format is recommended. Notice that ``default`` must be given positionally like ``example`` and - not using the named-argument syntax like ``default=example``. We hope to - be able to remove this limitation in the future. + not using the named-argument syntax like ``default=example``. Examples: | ${x} = `Get Variable Value` $a example @@ -1783,7 +1782,7 @@ def _get_logged_variable(self, name, variables): return name, value @run_keyword_variant(resolve=0) - def variable_should_exist(self, name, message=None): + def variable_should_exist(self, name, message=None, /): r"""Fails unless the given variable exists within the current scope. The name of the variable can be given either as a normal variable name @@ -1793,8 +1792,7 @@ def variable_should_exist(self, name, message=None): The default error message can be overridden with the ``message`` argument. Notice that it must be given positionally like ``A message`` and not - using the named-argument syntax like ``message=A message``. We hope to - be able to remove this limitation in the future. + using the named-argument syntax like ``message=A message``. See also `Variable Should Not Exist` and `Keyword Should Exist`. """ @@ -1809,7 +1807,7 @@ def variable_should_exist(self, name, message=None): ) @run_keyword_variant(resolve=0) - def variable_should_not_exist(self, name, message=None): + def variable_should_not_exist(self, name, message=None, /): r"""Fails if the given variable exists within the current scope. The name of the variable can be given either as a normal variable name @@ -1819,8 +1817,7 @@ def variable_should_not_exist(self, name, message=None): The default error message can be overridden with the ``message`` argument. Notice that it must be given positionally like ``A message`` and not - using the named-argument syntax like ``message=A message``. We hope to - be able to remove this limitation in the future. + using the named-argument syntax like ``message=A message``. See also `Variable Should Exist` and `Keyword Should Exist`. """ @@ -1889,7 +1886,7 @@ def set_variable(self, *values): return list(values) @run_keyword_variant(resolve=0) - def set_local_variable(self, name, *values): + def set_local_variable(self, name, /, *values): r"""Makes a variable available everywhere within the local scope. Variables set with this keyword are available within the @@ -1929,7 +1926,7 @@ def set_local_variable(self, name, *values): self._log_set_variable(name, value) @run_keyword_variant(resolve=0) - def set_test_variable(self, name, *values): + def set_test_variable(self, name, /, *values): r"""Makes a variable available everywhere within the scope of the current test. Variables set with this keyword are available everywhere within the @@ -1965,7 +1962,7 @@ def set_test_variable(self, name, *values): self._log_set_variable(name, value) @run_keyword_variant(resolve=0) - def set_task_variable(self, name, *values): + def set_task_variable(self, name, /, *values): """Makes a variable available everywhere within the scope of the current task. This is an alias for `Set Test Variable` that is more applicable when @@ -1977,7 +1974,7 @@ def set_task_variable(self, name, *values): self.set_test_variable(name, *values) @run_keyword_variant(resolve=0) - def set_suite_variable(self, name, *values): + def set_suite_variable(self, name, /, *values): r"""Makes a variable available everywhere within the scope of the current suite. Variables set with this keyword are available everywhere within the @@ -2050,7 +2047,7 @@ def set_suite_variable(self, name, *values): self._log_set_variable(name, value) @run_keyword_variant(resolve=0) - def set_global_variable(self, name, *values): + def set_global_variable(self, name, /, *values): r"""Makes a variable available globally in all tests and suites. Variables set with this keyword are globally available in all @@ -2135,7 +2132,7 @@ def _log_set_variable(self, name, value): class _RunKeyword(_BuiltInBase): @run_keyword_variant(resolve=0, dry_run=True) - def run_keyword(self, name, *args): + def run_keyword(self, name, /, *args): """Executes the given keyword with the given arguments. Because the name of the keyword to execute is given as an argument, it @@ -2280,7 +2277,7 @@ def _split_run_keywords_with_and(self, keywords): yield keywords @run_keyword_variant(resolve=1, dry_run=True) - def run_keyword_if(self, condition, name, *args): + def run_keyword_if(self, condition, name, /, *args): """Runs the given keyword with the given arguments, if ``condition`` is true. *NOTE:* Robot Framework 4.0 introduced built-in IF/ELSE support and using @@ -2363,7 +2360,7 @@ def _split_branch(self, args, control_word, required, required_error): return args[:index], branch @run_keyword_variant(resolve=1, dry_run=True) - def run_keyword_unless(self, condition, name, *args): + def run_keyword_unless(self, condition, name, /, *args): """*DEPRECATED since RF 5.0. Use Native IF/ELSE or `Run Keyword If` instead.* Runs the given keyword with the given arguments if ``condition`` is false. @@ -2376,7 +2373,7 @@ def run_keyword_unless(self, condition, name, *args): return self.run_keyword(name, *args) @run_keyword_variant(resolve=0, dry_run=True) - def run_keyword_and_ignore_error(self, name, *args): + def run_keyword_and_ignore_error(self, name, /, *args): """Runs the given keyword with the given arguments and ignores possible error. This keyword returns two values, so that the first is either string @@ -2402,7 +2399,7 @@ def run_keyword_and_ignore_error(self, name, *args): return "FAIL", str(err) @run_keyword_variant(resolve=0, dry_run=True) - def run_keyword_and_warn_on_failure(self, name, *args): + def run_keyword_and_warn_on_failure(self, name, /, *args): """Runs the specified keyword logs a warning if the keyword fails. This keyword is similar to `Run Keyword And Ignore Error` but if the executed @@ -2421,7 +2418,7 @@ def run_keyword_and_warn_on_failure(self, name, *args): return status, message @run_keyword_variant(resolve=0, dry_run=True) - def run_keyword_and_return_status(self, name, *args): + def run_keyword_and_return_status(self, name, /, *args): """Runs the given keyword with given arguments and returns the status as a Boolean value. This keyword returns Boolean ``True`` if the keyword that is executed @@ -2442,7 +2439,7 @@ def run_keyword_and_return_status(self, name, *args): return status == "PASS" @run_keyword_variant(resolve=0, dry_run=True) - def run_keyword_and_continue_on_failure(self, name, *args): + def run_keyword_and_continue_on_failure(self, name, /, *args): """Runs the keyword and continues execution even if a failure occurs. The keyword name and arguments work as with `Run Keyword`. @@ -2462,7 +2459,7 @@ def run_keyword_and_continue_on_failure(self, name, *args): raise err @run_keyword_variant(resolve=1, dry_run=True) - def run_keyword_and_expect_error(self, expected_error, name, *args): + def run_keyword_and_expect_error(self, expected_error, name, /, *args): """Runs the keyword and checks that the expected error occurred. The keyword to execute and its arguments are specified using ``name`` @@ -2540,7 +2537,7 @@ def _error_is_expected(self, error, expected_error): return matchers[prefix](error, expected_error.lstrip()) @run_keyword_variant(resolve=1, dry_run=True) - def repeat_keyword(self, repeat, name, *args): + def repeat_keyword(self, repeat, name, /, *args): """Executes the specified keyword multiple times. ``name`` and ``args`` define the keyword that is executed similarly as @@ -2618,7 +2615,7 @@ def _keywords_repeated_by_timeout(self, timeout, name, args): yield name, args @run_keyword_variant(resolve=2, dry_run=True) - def wait_until_keyword_succeeds(self, retry, retry_interval, name, *args): + def wait_until_keyword_succeeds(self, retry, retry_interval, name, /, *args): """Runs the specified keyword and retries if it fails. ``name`` and ``args`` define the keyword that is executed similarly @@ -2720,7 +2717,7 @@ def _reset_keyword_timeout_in_teardown(self, err, context): err.keyword_timeout = True @run_keyword_variant(resolve=1) - def set_variable_if(self, condition, *values): + def set_variable_if(self, condition, /, *values): """Sets variable based on the given condition. The basic usage is giving a condition and two values. The @@ -2780,7 +2777,7 @@ def _verify_values_for_set_variable_if(self, values): return values @run_keyword_variant(resolve=0, dry_run=True) - def run_keyword_if_test_failed(self, name, *args): + def run_keyword_if_test_failed(self, name, /, *args): """Runs the given keyword with the given arguments, if the test failed. This keyword can only be used in a test teardown. Trying to use it @@ -2794,7 +2791,7 @@ def run_keyword_if_test_failed(self, name, *args): return self.run_keyword(name, *args) @run_keyword_variant(resolve=0, dry_run=True) - def run_keyword_if_test_passed(self, name, *args): + def run_keyword_if_test_passed(self, name, /, *args): """Runs the given keyword with the given arguments, if the test passed. This keyword can only be used in a test teardown. Trying to use it @@ -2808,7 +2805,7 @@ def run_keyword_if_test_passed(self, name, *args): return self.run_keyword(name, *args) @run_keyword_variant(resolve=0, dry_run=True) - def run_keyword_if_timeout_occurred(self, name, *args): + def run_keyword_if_timeout_occurred(self, name, /, *args): """Runs the given keyword if either a test or a keyword timeout has occurred. This keyword can only be used in a test teardown. Trying to use it @@ -2828,7 +2825,7 @@ def _get_test_in_teardown(self, kwname): raise RuntimeError(f"Keyword '{kwname}' can only be used in test teardown.") @run_keyword_variant(resolve=0, dry_run=True) - def run_keyword_if_all_tests_passed(self, name, *args): + def run_keyword_if_all_tests_passed(self, name, /, *args): """Runs the given keyword with the given arguments, if all tests passed. This keyword can only be used in a suite teardown. Trying to use it @@ -2842,7 +2839,7 @@ def run_keyword_if_all_tests_passed(self, name, *args): return self.run_keyword(name, *args) @run_keyword_variant(resolve=0, dry_run=True) - def run_keyword_if_any_tests_failed(self, name, *args): + def run_keyword_if_any_tests_failed(self, name, /, *args): """Runs the given keyword with the given arguments, if one or more tests failed. This keyword can only be used in a suite teardown. Trying to use it @@ -3122,7 +3119,7 @@ def return_from_keyword_if(self, condition, *return_values): self._return_from_keyword(return_values) @run_keyword_variant(resolve=0, dry_run=True) - def run_keyword_and_return(self, name, *args): + def run_keyword_and_return(self, name, /, *args): """Runs the specified keyword and returns from the enclosing user keyword. The keyword to execute is defined with ``name`` and ``*args`` exactly @@ -3148,7 +3145,7 @@ def run_keyword_and_return(self, name, *args): self._return_from_keyword(return_values=[escape(ret)]) @run_keyword_variant(resolve=1, dry_run=True) - def run_keyword_and_return_if(self, condition, name, *args): + def run_keyword_and_return_if(self, condition, name, /, *args): """Runs the specified keyword and returns from the enclosing user keyword. A wrapper for `Run Keyword And Return` to run and return based on @@ -3218,7 +3215,7 @@ def pass_execution(self, message, *tags): raise PassExecution(message) @run_keyword_variant(resolve=1) - def pass_execution_if(self, condition, message, *tags): + def pass_execution_if(self, condition, message, /, *tags): """Conditionally skips rest of the current test, setup, or teardown with PASS status. A wrapper for `Pass Execution` to skip rest of the current test, @@ -3515,7 +3512,7 @@ def reload_library(self, name_or_instance): logger.info(f"Reloaded library {lib.name} with {len(lib.keywords)} keywords.") @run_keyword_variant(resolve=0) - def import_library(self, name, *args): + def import_library(self, name, /, *args): """Imports a library with the given name and optional arguments. This functionality allows dynamic importing of libraries while tests @@ -3551,7 +3548,7 @@ def _split_alias(self, args): return args, None @run_keyword_variant(resolve=0) - def import_variables(self, path, *args): + def import_variables(self, path, /, *args): """Imports a variable file with the given path and optional arguments. Variables imported with this keyword are set into the test suite scope @@ -3576,7 +3573,7 @@ def import_variables(self, path, *args): raise RuntimeError(str(err)) @run_keyword_variant(resolve=0) - def import_resource(self, path): + def import_resource(self, path, /): """Imports a resource file with the given path. Resources imported with this keyword are set into the test suite scope From d1a1cf4c33bac8956c9a9c8294d3b657a72b6eb3 Mon Sep 17 00:00:00 2001 From: Mikko Korpela Date: Wed, 27 Aug 2025 22:57:01 +0300 Subject: [PATCH 08/39] Remove rflogs installation and execution step Removed the installation and execution of rflogs from the workflow. --- .../workflows/acceptance_tests_cpython.yml | 37 ------------------- 1 file changed, 37 deletions(-) diff --git a/.github/workflows/acceptance_tests_cpython.yml b/.github/workflows/acceptance_tests_cpython.yml index 9159e39b794..a9a1d92f82b 100644 --- a/.github/workflows/acceptance_tests_cpython.yml +++ b/.github/workflows/acceptance_tests_cpython.yml @@ -89,40 +89,3 @@ jobs: name: at-results-${{ matrix.python-version }}-${{ matrix.os }} path: atest/results if: always() && job.status == 'failure' - - - name: Install and run rflogs - if: failure() - env: - RFLOGS_API_KEY: ${{ secrets.RFLOGS_API_KEY }} - BRANCH_NAME: ${{ github.head_ref || github.ref_name }} - working-directory: atest/results - shell: python - run: | - import os - import glob - import subprocess - - # Install rflogs - subprocess.check_call(["pip", "install", "rflogs"]) - - # Find the first directory containing log.html - log_files = glob.glob("**/log.html", recursive=True) - if log_files: - result_dir = os.path.dirname(log_files[0]) - print(f"Result directory: {result_dir}") - - # Construct the rflogs command - cmd = [ - "rflogs", "upload", - "--tag", f"workflow:${{ github.workflow }}", - "--tag", f"os:${{ runner.os }}", - "--tag", f"python-version:${{ matrix.python-version }}", - "--tag", f"branch:{os.environ['BRANCH_NAME']}", - result_dir - ] - - # Run rflogs upload - subprocess.check_call(cmd) - else: - print("No directory containing log.html found") - exit(1) From 9b3381f1307e9de638c8d0ee49860684c399691b Mon Sep 17 00:00:00 2001 From: Mikko Korpela Date: Wed, 27 Aug 2025 22:57:29 +0300 Subject: [PATCH 09/39] Remove acceptance test result archiving and rflogs Removed archiving of acceptance test results and rflogs installation. --- .../workflows/acceptance_tests_cpython_pr.yml | 44 ------------------- 1 file changed, 44 deletions(-) diff --git a/.github/workflows/acceptance_tests_cpython_pr.yml b/.github/workflows/acceptance_tests_cpython_pr.yml index 8f8cefd82b3..78a39f48feb 100644 --- a/.github/workflows/acceptance_tests_cpython_pr.yml +++ b/.github/workflows/acceptance_tests_cpython_pr.yml @@ -69,47 +69,3 @@ jobs: ${{ matrix.set_codepage }} ${{ matrix.set_display }} ${{ env.ATEST_PYTHON }} atest/run.py --interpreter ${{ env.BASE_PYTHON }} --exclude no-ci ${{ matrix.atest_args }} atest/robot - - - name: Archive acceptances test results - uses: actions/upload-artifact@v4 - with: - name: at-results-${{ matrix.python-version }}-${{ matrix.os }} - path: atest/results - if: always() && job.status == 'failure' - - - name: Install and run rflogs - if: failure() - env: - RFLOGS_API_KEY: ${{ secrets.RFLOGS_API_KEY }} - BRANCH_NAME: ${{ github.head_ref || github.ref_name }} - working-directory: atest/results - shell: python - run: | - import os - import glob - import subprocess - - # Install rflogs - subprocess.check_call(["pip", "install", "rflogs"]) - - # Find the first directory containing log.html - log_files = glob.glob("**/log.html", recursive=True) - if log_files: - result_dir = os.path.dirname(log_files[0]) - print(f"Result directory: {result_dir}") - - # Construct the rflogs command - cmd = [ - "rflogs", "upload", - "--tag", f"workflow:${{ github.workflow }}", - "--tag", f"os:${{ runner.os }}", - "--tag", f"python-version:${{ matrix.python-version }}", - "--tag", f"branch:{os.environ['BRANCH_NAME']}", - result_dir - ] - - # Run rflogs upload - subprocess.check_call(cmd) - else: - print("No directory containing log.html found") - exit(1) From fefbe21a40129dca01cbc22b035d3cbe69643bb8 Mon Sep 17 00:00:00 2001 From: Mikko Korpela Date: Thu, 28 Aug 2025 09:36:20 +0300 Subject: [PATCH 10/39] Archive acceptance test results if job fails Add step to archive acceptance test results on failure. --- .github/workflows/acceptance_tests_cpython_pr.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/acceptance_tests_cpython_pr.yml b/.github/workflows/acceptance_tests_cpython_pr.yml index 78a39f48feb..4476b386c0c 100644 --- a/.github/workflows/acceptance_tests_cpython_pr.yml +++ b/.github/workflows/acceptance_tests_cpython_pr.yml @@ -69,3 +69,10 @@ jobs: ${{ matrix.set_codepage }} ${{ matrix.set_display }} ${{ env.ATEST_PYTHON }} atest/run.py --interpreter ${{ env.BASE_PYTHON }} --exclude no-ci ${{ matrix.atest_args }} atest/robot + + - name: Archive acceptances test results + uses: actions/upload-artifact@v4 + with: + name: at-results-${{ matrix.python-version }}-${{ matrix.os }} + path: atest/results + if: always() && job.status == 'failure' From 749eddcfb4662ddcd072e27e788b6e73976343d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Fri, 29 Aug 2025 14:46:21 +0300 Subject: [PATCH 11/39] Remove duplicate imports This avoids warnings when #5492 is implemented. --- atest/robot/libdoc/libdoc_resource.robot | 1 - atest/robot/libdoc/spec_library.robot | 1 - atest/robot/running/try_except/try_except_resource.robot | 1 - 3 files changed, 3 deletions(-) diff --git a/atest/robot/libdoc/libdoc_resource.robot b/atest/robot/libdoc/libdoc_resource.robot index 8f08ec03f6a..9231ca93a39 100644 --- a/atest/robot/libdoc/libdoc_resource.robot +++ b/atest/robot/libdoc/libdoc_resource.robot @@ -1,7 +1,6 @@ *** Settings *** Resource atest_resource.robot Library LibDocLib.py ${INTERPRETER} -Library OperatingSystem *** Variables *** ${TESTDATADIR} ${DATADIR}/libdoc diff --git a/atest/robot/libdoc/spec_library.robot b/atest/robot/libdoc/spec_library.robot index 5bfa0089ef2..be96d7477ba 100644 --- a/atest/robot/libdoc/spec_library.robot +++ b/atest/robot/libdoc/spec_library.robot @@ -1,5 +1,4 @@ *** Settings *** -Library OperatingSystem Suite Setup Run Libdoc And Parse Output ${TESTDATADIR}/ExampleSpec.xml Resource libdoc_resource.robot diff --git a/atest/robot/running/try_except/try_except_resource.robot b/atest/robot/running/try_except/try_except_resource.robot index 590cc5ffd60..af65fc06c0d 100644 --- a/atest/robot/running/try_except/try_except_resource.robot +++ b/atest/robot/running/try_except/try_except_resource.robot @@ -1,6 +1,5 @@ *** Settings *** Resource atest_resource.robot -Library Collections *** Keywords *** Verify try except and block statuses From b4003c99f92861e3aa66f62fa02addccd2d15c22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Fri, 29 Aug 2025 15:39:22 +0300 Subject: [PATCH 12/39] Add owner info to dynamic imports This makes it possible to show source and line number in import errors/warnings also when using `Import Library/Resource/Variables` keywords for importing libraries. Need by #5492. --- .../listening_imports.robot | 6 +-- src/robot/running/namespace.py | 50 +++++++++++-------- src/robot/running/resourcemodel.py | 2 +- 3 files changed, 33 insertions(+), 25 deletions(-) diff --git a/atest/robot/output/listener_interface/listening_imports.robot b/atest/robot/output/listener_interface/listening_imports.robot index 9dfcb8d1ae2..e66c38abb59 100644 --- a/atest/robot/output/listener_interface/listening_imports.robot +++ b/atest/robot/output/listener_interface/listening_imports.robot @@ -73,19 +73,19 @@ Listen Imports ... Library ... OperatingSystem ... args: [] - ... importer: None + ... importer: //imports.robot ... originalname: OperatingSystem ... source: //OperatingSystem.py Expect ... Resource ... dynamically_imported_resource - ... importer: None + ... importer: //imports.robot ... source: //dynamically_imported_resource.robot Expect ... Variables ... vars.py ... args: [new, args] - ... importer: None + ... importer: //imports.robot ... source: //vars.py Verify Expected diff --git a/src/robot/running/namespace.py b/src/robot/running/namespace.py index ffe7a6f1c8e..8e92d344d12 100644 --- a/src/robot/running/namespace.py +++ b/src/robot/running/namespace.py @@ -62,7 +62,7 @@ def handle_imports(self): def _import_default_libraries(self): for name in self._default_libraries: - self.import_library(name, notify=name == "BuiltIn") + self._import_library(Import(Import.LIBRARY, name), notify=name == "BuiltIn") def _handle_imports(self, import_settings): for item in import_settings: @@ -82,17 +82,26 @@ def _import(self, import_setting): action(import_setting) def import_resource(self, name, overwrite=True): - self._import_resource(Import(Import.RESOURCE, name), overwrite=overwrite) + owner, lineno = self._current_owner_and_lineno() + import_ = Import(Import.RESOURCE, name, owner=owner, lineno=lineno) + self._import_resource(import_, overwrite=overwrite) - def _import_resource(self, import_setting, overwrite=False): - path = self._resolve_name(import_setting) + def _current_owner_and_lineno(self): + ctx = EXECUTION_CONTEXTS.current + if not ctx.steps: + return None, -1 + owner = ctx.steps[-1][0] + return owner, owner.lineno + + def _import_resource(self, import_, overwrite=False): + path = self._resolve_name(import_) self._validate_not_importing_init_file(path) if overwrite or path not in self._kw_store.resources: resource = IMPORTER.import_resource(path, self.languages) self.variables.set_from_variable_section(resource.variables, overwrite) self._kw_store.resources[path] = resource self._handle_imports(resource.imports) - LOGGER.resource_import(resource, import_setting) + LOGGER.resource_import(resource, import_) else: name = self._suite_name LOGGER.info(f"Resource file '{path}' already imported by suite '{name}'.") @@ -105,17 +114,19 @@ def _validate_not_importing_init_file(self, path): ) def import_variables(self, name, args, overwrite=False): - self._import_variables(Import(Import.VARIABLES, name, args), overwrite) + owner, lineno = self._current_owner_and_lineno() + import_ = Import(Import.VARIABLES, name, args, owner=owner, lineno=lineno) + self._import_variables(import_, overwrite=overwrite) - def _import_variables(self, import_setting, overwrite=False): - path = self._resolve_name(import_setting) - args = self._resolve_args(import_setting) + def _import_variables(self, import_, overwrite=False): + path = self._resolve_name(import_) + args = self._resolve_args(import_) if overwrite or (path, args) not in self._imported_variable_files: self._imported_variable_files.add((path, args)) self.variables.set_from_file(path, args, overwrite) LOGGER.variables_import( {"name": os.path.basename(path), "args": args, "source": path}, - importer=import_setting, + importer=import_, ) else: msg = f"Variable file '{path}'" @@ -123,24 +134,21 @@ def _import_variables(self, import_setting, overwrite=False): msg += f" with arguments {seq2str2(args)}" LOGGER.info(f"{msg} already imported by suite '{self._suite_name}'.") - def import_library(self, name, args=(), alias=None, notify=True): - self._import_library(Import(Import.LIBRARY, name, args, alias), notify=notify) + def import_library(self, name, args=(), alias=None): + owner, lineno = self._current_owner_and_lineno() + import_ = Import(Import.LIBRARY, name, args, alias, owner, lineno) + self._import_library(import_) - def _import_library(self, import_setting, notify=True): - name = self._resolve_name(import_setting) - lib = IMPORTER.import_library( - name, - import_setting.args, - import_setting.alias, - self.variables, - ) + def _import_library(self, import_, notify=True): + name = self._resolve_name(import_) + lib = IMPORTER.import_library(name, import_.args, import_.alias, self.variables) if lib.name in self._kw_store.libraries: LOGGER.info( f"Library '{lib.name}' already imported by suite '{self._suite_name}'." ) return if notify: - LOGGER.library_import(lib, import_setting) + LOGGER.library_import(lib, import_) self._kw_store.libraries[lib.name] = lib lib.scope_manager.start_suite() if self._running_test: diff --git a/src/robot/running/resourcemodel.py b/src/robot/running/resourcemodel.py index 6d661191f66..050cab37f90 100644 --- a/src/robot/running/resourcemodel.py +++ b/src/robot/running/resourcemodel.py @@ -378,7 +378,7 @@ def __init__( name: str, args: Sequence[str] = (), alias: "str|None" = None, - owner: "ResourceFile|None" = None, + owner: "ResourceFile|Keyword|None" = None, lineno: "int|None" = None, ): if type not in (self.LIBRARY, self.RESOURCE, self.VARIABLES): From f676a113ae57ea703974238e19bc32af4c538252 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Mon, 1 Sep 2025 15:08:26 +0300 Subject: [PATCH 13/39] Remove Jython compatibility in tests --- atest/robot/test_libraries/library_imports.robot | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/atest/robot/test_libraries/library_imports.robot b/atest/robot/test_libraries/library_imports.robot index fc03d601e16..c59e60b4501 100644 --- a/atest/robot/test_libraries/library_imports.robot +++ b/atest/robot/test_libraries/library_imports.robot @@ -1,5 +1,6 @@ *** Settings *** -Documentation Importing test libraries normally, using variable in library name, and importing libraries accepting arguments. +Documentation Importing test libraries normally, using variable in library name, +... and importing libraries accepting arguments. Suite Setup Run Tests ${EMPTY} test_libraries/library_import_normal.robot Resource atest_resource.robot @@ -15,9 +16,9 @@ Library Import With Spaces In Name Does Not Work ... traceback=None Importing Library Class Should Have Been Syslogged - ${source} = Normalize Path And Ignore Drive ${CURDIR}/../../../src/robot/libraries/OperatingSystem + ${source} = Normalize Path ${CURDIR}/../../../src/robot/libraries/OperatingSystem Syslog Should Contain Match | INFO \ | Imported library class 'robot.libraries.OperatingSystem' from '${source}*' - ${base} = Normalize Path And Ignore Drive ${CURDIR}/../../testresources/testlibs + ${base} = Normalize Path ${CURDIR}/../../testresources/testlibs Syslog Should Contain Match | INFO \ | Imported library module 'libmodule' from '${base}${/}libmodule*' Syslog Should Contain Match | INFO \ | Imported library class 'libmodule.LibClass2' from '${base}${/}libmodule*' @@ -58,10 +59,3 @@ Arguments To Library Check Test Case Two Default Parameters Check Test Case One Default and One Set Parameter Check Test Case Two Set Parameters - -*** Keywords *** -Normalize Path And Ignore Drive - [Arguments] ${path} - ${path} = Normalize Path ${path} - Return From Keyword If os.sep == '/' ${path} - Return From Keyword ?${path[1:]} From a44c474c49679f8870598b870f9b0bb96596b314 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Mon, 1 Sep 2025 15:43:45 +0300 Subject: [PATCH 14/39] Cleanup whitespace and case --- .../test_libraries/library_with_0_parameters.robot | 9 ++++----- .../test_libraries/library_with_1_parameters.robot | 12 +++++------- .../test_libraries/library_with_2_parameters.robot | 12 +++++------- 3 files changed, 14 insertions(+), 19 deletions(-) diff --git a/atest/testdata/test_libraries/library_with_0_parameters.robot b/atest/testdata/test_libraries/library_with_0_parameters.robot index d824d91c3ba..8166ac58d2a 100644 --- a/atest/testdata/test_libraries/library_with_0_parameters.robot +++ b/atest/testdata/test_libraries/library_with_0_parameters.robot @@ -2,8 +2,7 @@ Library ParameterLibrary *** Test Cases *** -Two Default Parameters - ${host} ${port} = parameters - should be equal ${host} localhost - should be equal ${port} 8080 - +Two default parameters + ${host} ${port} = Parameters + Should Be Equal ${host} localhost + Should Be Equal ${port} 8080 diff --git a/atest/testdata/test_libraries/library_with_1_parameters.robot b/atest/testdata/test_libraries/library_with_1_parameters.robot index c2e4cdf5994..7a4107e0d97 100644 --- a/atest/testdata/test_libraries/library_with_1_parameters.robot +++ b/atest/testdata/test_libraries/library_with_1_parameters.robot @@ -1,10 +1,8 @@ *** Settings *** -Library ParameterLibrary myhost +Library ParameterLibrary myhost *** Test Cases *** -One Default And One Set Parameter - [Documentation] Checks that parameter can be given to library and that one default value is also correct PASS - ${host} ${port} = parameters - should be equal ${host} myhost - should be equal ${port} 8080 - +One default and one set parameter + ${host} ${port} = Parameters + Should Be Equal ${host} myhost + Should Be Equal ${port} 8080 diff --git a/atest/testdata/test_libraries/library_with_2_parameters.robot b/atest/testdata/test_libraries/library_with_2_parameters.robot index 6c8f9b24bf4..a2fe75325da 100644 --- a/atest/testdata/test_libraries/library_with_2_parameters.robot +++ b/atest/testdata/test_libraries/library_with_2_parameters.robot @@ -1,10 +1,8 @@ *** Settings *** -Library ParameterLibrary myhost 1000 +Library ParameterLibrary myhost 1000 *** Test Cases *** -Two Set Parameters - [Documentation] Checks that parameters can be given to library PASS - ${host} ${port} = parameters - should be equal ${host} myhost - should be equal ${port} 1000 - +Two set parameters + ${host} ${port} = Parameters + Should Be Equal ${host} myhost + Should Be Equal ${port} 1000 From ede92c06259c970d1b02ad9e32c5295ac075cc71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Tue, 2 Sep 2025 11:33:10 +0300 Subject: [PATCH 15/39] Emit warning if library is reimported with different args Libraries imported with the same name as already imported libraries are ignored. This used to be silent, but after this commit there are warnings in these cases: - Library is re-imported with different arguments than earlier. - Library is imported using an alias that is used by another imported library. Fixes #5492. --- .../parsing/caching_libs_and_resources.robot | 4 +-- .../test_libraries/library_imports.robot | 13 +++++++++- atest/robot/test_libraries/with_name.robot | 21 ++++++++++++++-- .../library_import_normal.robot | 1 + .../library_with_1_parameters.robot | 2 ++ .../testdata/test_libraries/with_name_1.robot | 5 ++++ src/robot/running/namespace.py | 25 ++++++++++++++++--- 7 files changed, 63 insertions(+), 8 deletions(-) diff --git a/atest/robot/parsing/caching_libs_and_resources.robot b/atest/robot/parsing/caching_libs_and_resources.robot index 4a543f8a3d0..3276fc81d6c 100644 --- a/atest/robot/parsing/caching_libs_and_resources.robot +++ b/atest/robot/parsing/caching_libs_and_resources.robot @@ -11,8 +11,8 @@ Import Libraries Only Once Should Contain X Times ${SYSLOG} Found library 'BuiltIn' with arguments [ ] from cache. 2 Should Contain X Times ${SYSLOG} Imported library 'OperatingSystem' with arguments [ ] (version 1 Should Contain X Times ${SYSLOG} Found library 'OperatingSystem' with arguments [ ] from cache. 3 - Syslog Should Contain | INFO \ | Library 'OperatingSystem' already imported by suite 'Library Caching.File1'. - Syslog Should Contain | INFO \ | Library 'OperatingSystem' already imported by suite 'Library Caching.File2'. + Syslog Should Contain | INFO \ | Suite 'Library Caching.File1' has already imported library 'OperatingSystem' with same arguments. This import is ignored. + Syslog Should Contain | INFO \ | Suite 'Library Caching.File2' has already imported library 'OperatingSystem' with same arguments. This import is ignored. Process Resource Files Only Once [Setup] Run Tests And Set $SYSLOG parsing/resource_parsing diff --git a/atest/robot/test_libraries/library_imports.robot b/atest/robot/test_libraries/library_imports.robot index c59e60b4501..203688aa5a0 100644 --- a/atest/robot/test_libraries/library_imports.robot +++ b/atest/robot/test_libraries/library_imports.robot @@ -36,6 +36,9 @@ Importing Python Class From Module Namespace is initialized during library init Check Test Case ${TEST NAME} +Second import without parameters is ignored without warning + Syslog Should Contain | INFO \ | Suite 'Library Import Normal' has already imported library 'libmodule' with same arguments. This import is ignored. + Library Import With Variables Run Tests ${EMPTY} test_libraries/library_import_with_variable.robot Check Test Case Verify Library Import With Variable In Name @@ -55,7 +58,15 @@ Arguments To Library ... test_libraries/library_with_0_parameters.robot ... test_libraries/library_with_1_parameters.robot ... test_libraries/library_with_2_parameters.robot - Run Tests ${EMPTY} ${sources} + Run Tests --name Root ${sources} Check Test Case Two Default Parameters Check Test Case One Default and One Set Parameter Check Test Case Two Set Parameters + +Second import with same parameters is ignored without warning + Syslog Should Contain | INFO \ | Suite 'Root.Library With 1 Parameters' has already imported library 'ParameterLibrary' with same arguments. This import is ignored. + +Second import with different parameters is ignored with warning + Error in file 0 test_libraries/library_with_1_parameters.robot 4 + ... Suite 'Root.Library With 1 Parameters' has already imported library 'ParameterLibrary' with different arguments. This import is ignored. + ... level=WARN diff --git a/atest/robot/test_libraries/with_name.robot b/atest/robot/test_libraries/with_name.robot index df12d918eaf..937d5d475ac 100644 --- a/atest/robot/test_libraries/with_name.robot +++ b/atest/robot/test_libraries/with_name.robot @@ -128,6 +128,24 @@ With Name When Library Arguments Are Not Strings 'WITH NAME' cannot come from variable with 'Import Library' keyword even when list variable opened Check Test Case ${TEST NAME} +Import with alias matching different library name is ignored with warning + Error In File 1 test_libraries/with_name_1.robot 10 + ... Suite 'Root.With Name 1' has already imported another library with name 'OperatingSystem'. This import is ignored. + ... level=WARN + +Import with alias matching different library alias is ignored with warning + Error In File 2 test_libraries/with_name_1.robot 11 + ... Suite 'Root.With Name 1' has already imported another library with name 'Params'. This import is ignored. + ... level=WARN + +Second import with different parameters and same alias is ignored with warning + Error In File 0 test_libraries/with_name_1.robot 8 + ... Suite 'Root.With Name 1' has already imported library 'ParameterLibrary' with different arguments. This import is ignored. + ... level=WARN + +Second import with same parameters and same alias is ignored without warning + Syslog Should Contain | INFO \ | Suite 'Root.With Name 1' has already imported library 'Params' with same arguments. This import is ignored. + *** Keywords *** Run 'With Name' Tests ${sources} = Catenate @@ -135,5 +153,4 @@ Run 'With Name' Tests ... test_libraries/with_name_2.robot ... test_libraries/with_name_3.robot ... test_libraries/with_name_4.robot - Run Tests ${EMPTY} ${sources} - Should Be Equal ${SUITE.name} With Name 1 & With Name 2 & With Name 3 & With Name 4 + Run Tests --name Root ${sources} diff --git a/atest/testdata/test_libraries/library_import_normal.robot b/atest/testdata/test_libraries/library_import_normal.robot index 4ee689017ea..a269ea5bcb4 100644 --- a/atest/testdata/test_libraries/library_import_normal.robot +++ b/atest/testdata/test_libraries/library_import_normal.robot @@ -5,6 +5,7 @@ Library libmodule.LibClass1 Library libmodule.LibClass2 Library libmodule Library NamespaceUsingLibrary +Library libmodule *** Test Cases *** Normal Library Import diff --git a/atest/testdata/test_libraries/library_with_1_parameters.robot b/atest/testdata/test_libraries/library_with_1_parameters.robot index 7a4107e0d97..ba4960d8c20 100644 --- a/atest/testdata/test_libraries/library_with_1_parameters.robot +++ b/atest/testdata/test_libraries/library_with_1_parameters.robot @@ -1,5 +1,7 @@ *** Settings *** Library ParameterLibrary myhost +Library ParameterLibrary myhost +Library ParameterLibrary different! *** Test Cases *** One default and one set parameter diff --git a/atest/testdata/test_libraries/with_name_1.robot b/atest/testdata/test_libraries/with_name_1.robot index f9e8a443b22..8b7635b2e2b 100644 --- a/atest/testdata/test_libraries/with_name_1.robot +++ b/atest/testdata/test_libraries/with_name_1.robot @@ -6,6 +6,11 @@ Library libraryscope.Global WITH NAME GlobalScope Library libraryscope.Suite AS Suite Scope Library libraryscope.Test WITH NAME TEST SCOPE Library ParameterLibrary ${1} 2 +# Duplicate import handling +Library DateTime AS OperatingSystem +Library Collections AS Params +Library ParameterLibrary AS Params +Library ParameterLibrary before1with before2with AS Params *** Test Cases *** Import Library Normally Before Importing With Name In Another Suite diff --git a/src/robot/running/namespace.py b/src/robot/running/namespace.py index 8e92d344d12..f523b6be828 100644 --- a/src/robot/running/namespace.py +++ b/src/robot/running/namespace.py @@ -143,9 +143,7 @@ def _import_library(self, import_, notify=True): name = self._resolve_name(import_) lib = IMPORTER.import_library(name, import_.args, import_.alias, self.variables) if lib.name in self._kw_store.libraries: - LOGGER.info( - f"Library '{lib.name}' already imported by suite '{self._suite_name}'." - ) + self._duplicate_library_import_warning(lib, import_.source, import_.lineno) return if notify: LOGGER.library_import(lib, import_) @@ -177,6 +175,27 @@ def _is_import_by_path(self, import_type, path): return path.lower().endswith(self._variables_import_by_path_ends) return True + def _duplicate_library_import_warning(self, lib, source, lineno): + prev = self._kw_store.libraries[lib.name] + prefix = f"Error in file '{source}' on line {lineno}: " if source else "" + level = "WARN" + if lib.real_name != prev.real_name: + explanation = f"another library with name '{lib.name}'" + elif ( + lib.init.positional != prev.init.positional + or lib.init.named != prev.init.named + ): + explanation = f"library '{lib.name}' with different arguments" + else: + explanation = f"library '{lib.name}' with same arguments" + prefix = "" + level = "INFO" + LOGGER.write( + f"{prefix}Suite '{self._suite_name}' has already imported {explanation}. " + f"This import is ignored.", + level + ) + def _resolve_args(self, import_setting): try: return self.variables.replace_list(import_setting.args) From ce7f99549935931dbced5d5f1ea20dc19e177eef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Tue, 2 Sep 2025 12:10:09 +0300 Subject: [PATCH 16/39] Enhance documentation related to importing same library multiple times Fixes #5122. --- .../CreatingTestLibraries.rst | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst index 01279797cf8..b414f82e529 100644 --- a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst +++ b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst @@ -174,6 +174,22 @@ Example implementations for the libraries used in the above example: else: do_something_in_other_environments() +If a library is imported multiple times with different arguments within a single +suite, it needs to be given a `custom name`__ or otherwise latter imports are ignored: + +.. sourcecode:: robotframework + + *** Settings *** + Library MyLibrary 10.0.0.1 8080 AS RemoteLibrary + Library MyLibrary 127.0.0.1 AS LocalLibrary + + *** Test Cases *** + Example + RemoteLibrary.Send Message Hello! + LocalLibrary.Send Message Hi! + +__ `Setting custom name to library`_ + Library scope ~~~~~~~~~~~~~ From 0feb76b0bceb0bd84713c72eded0e5f1ec3ecb4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Tue, 2 Sep 2025 14:56:39 +0300 Subject: [PATCH 17/39] Doc cleanup Remove class references from docs when method has a return type. --- src/robot/result/model.py | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/src/robot/result/model.py b/src/robot/result/model.py index 9908e33666b..85e0d657c37 100644 --- a/src/robot/result/model.py +++ b/src/robot/result/model.py @@ -608,11 +608,11 @@ def __init__( @setter def body(self, body: "Sequence[BodyItem|DataDict]") -> Body: - """Child keywords and messages as a :class:`~.Body` object. + """Child messages and possible other constructs. - Typically empty. Only contains something if running VAR has failed - due to a syntax error or listeners have logged messages or executed - keywords. + Contains the message logged about assignment. Contains something else + only if running VAR has failed due to a syntax error or listeners have + logged messages or executed keywords. """ return self.body_class(self, body) @@ -652,7 +652,7 @@ def __init__( @setter def body(self, body: "Sequence[BodyItem|DataDict]") -> Body: - """Child keywords and messages as a :class:`~.Body` object. + """Child keywords and messages. Typically empty. Only contains something if running RETURN has failed due to a syntax error or listeners have logged messages or executed @@ -691,7 +691,7 @@ def __init__( @setter def body(self, body: "Sequence[BodyItem|DataDict]") -> Body: - """Child keywords and messages as a :class:`~.Body` object. + """Child keywords and messages. Typically empty. Only contains something if running CONTINUE has failed due to a syntax error or listeners have logged messages or executed @@ -730,7 +730,7 @@ def __init__( @setter def body(self, body: "Sequence[BodyItem|DataDict]") -> Body: - """Child keywords and messages as a :class:`~.Body` object. + """Child keywords and messages. Typically empty. Only contains something if running BREAK has failed due to a syntax error or listeners have logged messages or executed @@ -770,10 +770,7 @@ def __init__( @setter def body(self, body: "Sequence[BodyItem|DataDict]") -> Body: - """Messages as a :class:`~.Body` object. - - Typically contains the message that caused the error. - """ + """Body typically containing only the related error message.""" return self.body_class(self, body) def to_dict(self) -> DataDict: @@ -841,7 +838,7 @@ def __init__( @setter def body(self, body: "Sequence[BodyItem|DataDict]") -> Body: - """Keyword body as a :class:`~.Body` object. + """Keyword body. Body can consist of child keywords, messages, and control structures such as IF/ELSE. @@ -903,7 +900,7 @@ def sourcename(self, name: str): @property def setup(self) -> "Keyword": - """Keyword setup as a :class:`Keyword` object. + """Keyword setup. See :attr:`teardown` for more information. New in Robot Framework 7.0. """ @@ -925,7 +922,7 @@ def has_setup(self) -> bool: @property def teardown(self) -> "Keyword": - """Keyword teardown as a :class:`Keyword` object. + """Keyword teardown. Teardown can be modified by setting attributes directly:: @@ -976,7 +973,7 @@ def has_teardown(self) -> bool: @setter def tags(self, tags: Sequence[str]) -> model.Tags: - """Keyword tags as a :class:`~.model.tags.Tags` object.""" + """Keyword tags.""" return Tags(tags) def to_dict(self) -> DataDict: @@ -1037,7 +1034,7 @@ def not_run(self) -> bool: @setter def body(self, body: "Sequence[BodyItem|DataDict]") -> Body: - """Test body as a :class:`~robot.result.Body` object.""" + """Test body.""" return self.body_class(self, body) def to_dict(self) -> DataDict: @@ -1126,7 +1123,7 @@ def status(self) -> Literal["PASS", "SKIP", "FAIL"]: @property def statistics(self) -> TotalStatistics: - """Suite statistics as a :class:`~robot.model.totalstatistics.TotalStatistics` object. + """Suite statistics. Recreated every time this property is accessed, so saving the results to a variable and inspecting it is often a good idea:: From 49c29aff3f9b38a6667d54cee1bacee3097af0f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Tue, 2 Sep 2025 14:57:27 +0300 Subject: [PATCH 18/39] Cleanup Add explicit `return`. --- src/robot/running/suiterunner.py | 29 +++++++++++++------------- src/robot/running/userkeywordrunner.py | 9 ++++---- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src/robot/running/suiterunner.py b/src/robot/running/suiterunner.py index 127cf5e8ea3..5405c841154 100644 --- a/src/robot/running/suiterunner.py +++ b/src/robot/running/suiterunner.py @@ -276,21 +276,22 @@ def _run_teardown( status: "SuiteStatus|TestStatus", result: "SuiteResult|TestResult", ): - if status.teardown_allowed: - if item.has_teardown: - exception = self._run_setup_or_teardown(item.teardown, result.teardown) + if not status.teardown_allowed: + return None + if item.has_teardown: + exception = self._run_setup_or_teardown(item.teardown, result.teardown) + else: + exception = None + status.teardown_executed(exception) + failed = exception and not isinstance(exception, PassExecution) + if isinstance(result, TestResult) and exception: + if failed or status.skipped or exception.skip: + result.message = status.message else: - exception = None - status.teardown_executed(exception) - failed = exception and not isinstance(exception, PassExecution) - if isinstance(result, TestResult) and exception: - if failed or status.skipped or exception.skip: - result.message = status.message - else: - # Pass execution used in teardown, - # and it overrides previous failure message - result.message = exception.message - return exception if failed else None + # Pass execution has been used in teardown, + # and it overrides previous failure message + result.message = exception.message + return exception if failed else None def _run_setup_or_teardown(self, data: KeywordData, result: KeywordResult): try: diff --git a/src/robot/running/userkeywordrunner.py b/src/robot/running/userkeywordrunner.py index b6b69e99b52..9f7857ba660 100644 --- a/src/robot/running/userkeywordrunner.py +++ b/src/robot/running/userkeywordrunner.py @@ -48,11 +48,12 @@ def run(self, data: KeywordData, result: KeywordResult, context, run=True): self._validate(kw) if kw.private: context.warn_on_invalid_private_call(kw) + if not run: + return None with assignment.assigner(context) as assigner: - if run: - return_value = self._run(data, kw, result, context) - assigner.assign(return_value) - return return_value + return_value = self._run(data, kw, result, context) + assigner.assign(return_value) + return return_value def _config_result( self, From 3545497187bef94872cfd14f65814092598b5315 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Wed, 3 Sep 2025 13:56:45 +0300 Subject: [PATCH 19/39] Update Python versions used by CI test runs - Run tests also with Python 3.14 (#5435). Hopefully using just '3.14' is enough when it's still in the RC phase. - Run unit tests with PRs with same Python versions as acceptance tests. --- .github/workflows/acceptance_tests_cpython.yml | 2 +- .github/workflows/unit_tests.yml | 2 +- .github/workflows/unit_tests_pr.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/acceptance_tests_cpython.yml b/.github/workflows/acceptance_tests_cpython.yml index a9a1d92f82b..b8e5155bef3 100644 --- a/.github/workflows/acceptance_tests_cpython.yml +++ b/.github/workflows/acceptance_tests_cpython.yml @@ -19,7 +19,7 @@ jobs: fail-fast: false matrix: os: [ 'ubuntu-latest', 'windows-latest' ] - python-version: [ '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', 'pypy-3.10' ] + python-version: [ '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3,14', 'pypy-3.10' ] include: - os: ubuntu-latest set_display: export DISPLAY=:99; Xvfb :99 -screen 0 1024x768x24 -ac -noreset & sleep 3 diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 8165f4cbc93..b5e5c4e90d1 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -20,7 +20,7 @@ jobs: fail-fast: false matrix: os: [ 'ubuntu-latest', 'windows-latest' ] - python-version: [ '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', 'pypy-3.8' ] + python-version: [ '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14', 'pypy-3.8' ] exclude: - os: windows-latest python-version: 'pypy-3.8' diff --git a/.github/workflows/unit_tests_pr.yml b/.github/workflows/unit_tests_pr.yml index 8f648d27f20..208c3d42a8f 100644 --- a/.github/workflows/unit_tests_pr.yml +++ b/.github/workflows/unit_tests_pr.yml @@ -15,7 +15,7 @@ jobs: fail-fast: true matrix: os: [ 'ubuntu-latest', 'windows-latest' ] - python-version: [ '3.8', '3.12' ] + python-version: [ '3.8', '3.13' ] runs-on: ${{ matrix.os }} From 8f0d09860ba097cb1335820393926c49e1f214e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Wed, 3 Sep 2025 14:03:54 +0300 Subject: [PATCH 20/39] Fix Python 3.14 version config Trying to setup running tests with Python 3.14 on GitHub Actions (#5435). Using '3.14' as the version didn't work, because Python 3.14 is still in the RC phase. Using '3.14-dev' ought give us the latest preview release (and hopefully the final version vhen it's released): https://github.com/actions/setup-python/blob/main/docs/advanced-usage.md#using-the-python-version-input --- .github/workflows/acceptance_tests_cpython.yml | 2 +- .github/workflows/unit_tests.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/acceptance_tests_cpython.yml b/.github/workflows/acceptance_tests_cpython.yml index b8e5155bef3..4c11f6dfef0 100644 --- a/.github/workflows/acceptance_tests_cpython.yml +++ b/.github/workflows/acceptance_tests_cpython.yml @@ -19,7 +19,7 @@ jobs: fail-fast: false matrix: os: [ 'ubuntu-latest', 'windows-latest' ] - python-version: [ '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3,14', 'pypy-3.10' ] + python-version: [ '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3,14-dev', 'pypy-3.10' ] include: - os: ubuntu-latest set_display: export DISPLAY=:99; Xvfb :99 -screen 0 1024x768x24 -ac -noreset & sleep 3 diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index b5e5c4e90d1..7359abd2395 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -20,7 +20,7 @@ jobs: fail-fast: false matrix: os: [ 'ubuntu-latest', 'windows-latest' ] - python-version: [ '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14', 'pypy-3.8' ] + python-version: [ '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14-dev', 'pypy-3.8' ] exclude: - os: windows-latest python-version: 'pypy-3.8' From 53b260d7cb249fdbd14d9ed60c6897044a992eba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Wed, 3 Sep 2025 14:09:42 +0300 Subject: [PATCH 21/39] '3,14' -> '3.14' (#5435) --- .github/workflows/acceptance_tests_cpython.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/acceptance_tests_cpython.yml b/.github/workflows/acceptance_tests_cpython.yml index 4c11f6dfef0..40b4a1b1ed9 100644 --- a/.github/workflows/acceptance_tests_cpython.yml +++ b/.github/workflows/acceptance_tests_cpython.yml @@ -19,7 +19,7 @@ jobs: fail-fast: false matrix: os: [ 'ubuntu-latest', 'windows-latest' ] - python-version: [ '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3,14-dev', 'pypy-3.10' ] + python-version: [ '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14-dev', 'pypy-3.10' ] include: - os: ubuntu-latest set_display: export DISPLAY=:99; Xvfb :99 -screen 0 1024x768x24 -ac -noreset & sleep 3 From ceb53147f7c72134785f1429583dc244c195270e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Thu, 4 Sep 2025 13:45:02 +0300 Subject: [PATCH 22/39] VAR: Depecate scalars without value in non-local scope In practice using VAR ${scalar} scope=TEST will cause a deprecation warning and should be replaced with VAR ${scalar} ${EMPTY} scope=TEST No changes to creating @{list} and &{dict} variables or when using the local scope implicitly or explicitly. Fixes #5439. --- atest/robot/variables/var_syntax.robot | 18 ++++++++++++++ .../variables/var_syntax/suite1.robot | 24 +++++++++++++++++++ src/robot/running/model.py | 10 ++++++++ 3 files changed, 52 insertions(+) diff --git a/atest/robot/variables/var_syntax.robot b/atest/robot/variables/var_syntax.robot index 61808215839..ea796eaa72c 100644 --- a/atest/robot/variables/var_syntax.robot +++ b/atest/robot/variables/var_syntax.robot @@ -78,6 +78,24 @@ Scopes Check Test Case ${TESTNAME} 2 Check Test Case ${TESTNAME} 3 +Scalar without value when using non-local scope is deprecated + VAR ${message} + ... Using the VAR syntax to create a scalar variable without a value in + ... other than the local scope like 'VAR \ \ \ \${scalar} \ \ \ scope=SUITE' + ... is deprecated. In the future this syntax will promote an existing variable + ... to the new scope. Use 'VAR \ \ \ \${scalar} \ \ \ \${EMPTY} \ \ \ scope=SUITE' + ... instead. + ${tc} = Check Test Case ${TESTNAME} 1 + Check Log Message ${tc[1, 0]} ${message} level=WARN + Check Log Message ${tc[1, 1]} \${scalar} = + Check Log Message ${ERRORS}[0] ${message} level=WARN + Check Test Case ${TESTNAME} 2 + +List and dict without value when using non-local scope creates empty value + Check Test Case ${TESTNAME} 1 + Check Test Case ${TESTNAME} 2 + Length Should Be ${ERRORS} 1 + Invalid scope Check Test Case ${TESTNAME} diff --git a/atest/testdata/variables/var_syntax/suite1.robot b/atest/testdata/variables/var_syntax/suite1.robot index 48d9f0592a9..15a76f57329 100644 --- a/atest/testdata/variables/var_syntax/suite1.robot +++ b/atest/testdata/variables/var_syntax/suite1.robot @@ -6,6 +6,8 @@ Suite Teardown VAR in suite setup and teardown suite1 teardown Scalar VAR ${name} value Should Be Equal ${name} value + VAR ${name} + Should Be Equal ${name} ${EMPTY} Scalar with separator VAR ${a} ${1} 2 3 separator=\n @@ -24,10 +26,14 @@ Scalar with separator List VAR @{name} v1 v2 separator=v3 Should Be Equal ${name} ${{['v1', 'v2', 'separator=v3']}} + VAR @{name} + Should Be Equal ${name} ${{[]}} Dict VAR &{name} k1=v1 k2=v2 separator=v3 Should Be Equal ${name} ${{{'k1': 'v1', 'k2': 'v2', 'separator': 'v3'}}} + VAR &{name} + Should Be Equal ${name} ${{{}}} Long values ${items} = Create List @@ -99,6 +105,24 @@ Scopes 2 Should Be Equal ${GLOBAL} global Should Be Equal ${ROOT} set in root suite setup +Scalar without value when using non-local scope is deprecated 1 + VAR ${scalar} value + VAR ${scalar} scope=SUITE + Should Be Equal ${scalar} ${EMPTY} + +Scalar without value when using non-local scope is deprecated 2 + Should Be Equal ${scalar} ${EMPTY} + +List and dict without value when using non-local scope creates empty value 1 + VAR @{LIST} scope=SUITE + VAR &{DICT} scope=GLOBAL + Should Be Equal ${LIST} ${{[]}} + Should Be Equal ${DICT} ${{{}}} + +List and dict without value when using non-local scope creates empty value 2 + Should Be Equal ${LIST} ${{[]}} + Should Be Equal ${DICT} ${{{}}} + Invalid scope [Documentation] FAIL VAR option 'scope' does not accept value 'invalid'. Valid values are 'LOCAL', 'TEST', 'TASK', 'SUITE', 'SUITES' and 'GLOBAL'. VAR ${x} x scope=invalid diff --git a/src/robot/running/model.py b/src/robot/running/model.py index b3e7e6cabdf..ff951226df1 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -469,6 +469,16 @@ def run(self, result, context, run=True, templated=False): set_variable = getattr(context.variables, f"set_{scope}") try: name, value = self._resolve_name_and_value(context.variables) + if scope != 'local' and not value and name[:1] == '$': + context.warn( + f"Using the VAR syntax to create a scalar variable without " + f"a value in other than the local scope like " + f"'VAR {name} scope={scope.upper()}' " + f"is deprecated. In the future this syntax will promote " + f"an existing variable to the new scope. Use " + f"'VAR {name} ${{EMPTY}} scope={scope.upper()}' " + f"instead." + ) set_variable(name, value, **config) context.info(format_assign_message(name, value)) except DataError as err: From 168dfc259bd7b77b592af4ba88fb14e892f3d518 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Fri, 5 Sep 2025 13:12:31 +0300 Subject: [PATCH 23/39] formatting --- src/robot/running/model.py | 2 +- src/robot/running/namespace.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/robot/running/model.py b/src/robot/running/model.py index ff951226df1..92abcbee182 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -469,7 +469,7 @@ def run(self, result, context, run=True, templated=False): set_variable = getattr(context.variables, f"set_{scope}") try: name, value = self._resolve_name_and_value(context.variables) - if scope != 'local' and not value and name[:1] == '$': + if scope != "local" and not value and name[:1] == "$": context.warn( f"Using the VAR syntax to create a scalar variable without " f"a value in other than the local scope like " diff --git a/src/robot/running/namespace.py b/src/robot/running/namespace.py index f523b6be828..ee828b3cd45 100644 --- a/src/robot/running/namespace.py +++ b/src/robot/running/namespace.py @@ -193,7 +193,7 @@ def _duplicate_library_import_warning(self, lib, source, lineno): LOGGER.write( f"{prefix}Suite '{self._suite_name}' has already imported {explanation}. " f"This import is ignored.", - level + level, ) def _resolve_args(self, import_setting): From 0ec48070fb48d010e9cfc2d83e11a8d200475517 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Fri, 5 Sep 2025 14:41:46 +0300 Subject: [PATCH 24/39] Support `--no-status-rc` with `--help` and `--version` Fixes #3874. --- atest/robot/cli/rebot/help_and_version.robot | 32 ++++++++++++---- atest/robot/cli/runner/help_and_version.robot | 37 ++++++++++++++----- .../src/ExecutingTestCases/BasicUsage.rst | 36 +++++++++++------- src/robot/errors.py | 4 ++ src/robot/utils/application.py | 12 +++--- src/robot/utils/argumentparser.py | 36 +++++++++--------- 6 files changed, 100 insertions(+), 57 deletions(-) diff --git a/atest/robot/cli/rebot/help_and_version.robot b/atest/robot/cli/rebot/help_and_version.robot index 9c5f7e03526..d1b498e692c 100644 --- a/atest/robot/cli/rebot/help_and_version.robot +++ b/atest/robot/cli/rebot/help_and_version.robot @@ -2,12 +2,28 @@ Resource rebot_cli_resource.robot *** Test Cases *** -Help - ${result} = Run Rebot --help output=NONE - Should Be Equal ${result.rc} ${251} +---help + ${result} = Run Rebot --help output=NONE + Validate --help ${result} + +--help --no-status-rc + ${result} = Run Rebot --help --no-status-rc output=NONE + Validate --help ${result} rc=0 + +--version + ${result} = Run Rebot --version output=NONE + Validate --version ${result} + +--version --no-status-rc + ${result} = Run Rebot --VERSION --NoStatusRC output=NONE + Validate --version ${result} rc=0 + +*** Keywords *** +Validate --help + [Arguments] ${result} ${rc}=251 + Should Be Equal ${result.rc} ${rc} type=int Should Be Empty ${result.stderr} - ${help} = Set Variable ${result.stdout} - Log ${help} + VAR ${help} ${result.stdout} Should Start With ${help} Rebot -- Robot Framework report and log generator\n\nVersion: \ Should End With ${help} \n$ python -m robot.rebot --name Combined outputs/*.xml\n Should Not Contain ${help} \t @@ -20,9 +36,9 @@ Help Log Many @{tail} Should Be Empty ${tail} Help lines with trailing spaces -Version - ${result} = Run Rebot --version output=NONE - Should Be Equal ${result.rc} ${251} +Validate --version + [Arguments] ${result} ${rc}=251 + Should Be Equal ${result.rc} ${rc} type=int Should Be Empty ${result.stderr} Should Match Regexp ${result.stdout} ... ^Rebot [567]\\.\\d(\\.\\d)?((a|b|rc)\\d)?(\\.dev\\d)? \\((Python|PyPy) 3\\.[\\d.]+.* on .+\\)$ diff --git a/atest/robot/cli/runner/help_and_version.robot b/atest/robot/cli/runner/help_and_version.robot index 5756ba6873a..32ace5b2edd 100644 --- a/atest/robot/cli/runner/help_and_version.robot +++ b/atest/robot/cli/runner/help_and_version.robot @@ -2,18 +2,35 @@ Resource cli_resource.robot *** Test Cases *** -Help - ${result} = Run Tests --help output=NONE - Should Be Equal ${result.rc} ${251} +---help + ${result} = Run Tests --help output=NONE + Validate --help ${result} + +--help --no-status-rc + ${result} = Run Tests --help --no-status-rc output=NONE + Validate --help ${result} rc=0 + +--version + ${result} = Run Tests --version output=NONE + Validate --version ${result} + +--version --no-status-rc + ${result} = Run Tests --VERSION --NoStatusRC output=NONE + Validate --version ${result} rc=0 + +*** Keywords *** +Validate --help + [Arguments] ${result} ${rc}=251 + Should Be Equal ${result.rc} ${rc} type=int Should Be Empty ${result.stderr} - ${help} = Set Variable ${result.stdout} - Log ${help} + VAR ${help} ${result.stdout} Should Start With ${help} Robot Framework -- A generic automation framework\n\nVersion: \ - ${end} = Catenate SEPARATOR=\n + VAR ${end} ... \# Setting default options and syslog file before running tests. ... $ export ROBOT_OPTIONS="--outputdir results --suitestatlevel 2" ... $ export ROBOT_SYSLOG_FILE=/tmp/syslog.txt ... $ robot tests.robot + ... separator=\n Should End With ${help} \n\n${end}\n Should Not Contain ${help} \t Should Not Contain ${help} [ ERROR ] @@ -25,10 +42,10 @@ Help Log Many @{tail} Should Be Empty ${tail} Help lines with trailing spaces -Version - ${result} = Run Tests --version output=NONE - Should Be Equal ${result.rc} ${251} +Validate --version + [Arguments] ${result} ${rc}=251 + Should Be Equal ${result.rc} ${rc} type=int Should Be Empty ${result.stderr} Should Match Regexp ${result.stdout} - ... ^Robot Framework [567]\\.\\d(\\.\\d)?((a|b|rc)\\d)?(\\.dev\\d)? \\((Python|PyPy) 3\\.[\\d.]+.* on .+\\)$ + ... ^Robot Framework [78]\\.\\d(\\.\\d)?((a|b|rc)\\d)?(\\.dev\\d)? \\((Python|PyPy) 3\\.[\\d.]+.* on .+\\)$ Should Be True len($result.stdout) < 80 Too long version line diff --git a/doc/userguide/src/ExecutingTestCases/BasicUsage.rst b/doc/userguide/src/ExecutingTestCases/BasicUsage.rst index 40655648d40..6877d8a651d 100644 --- a/doc/userguide/src/ExecutingTestCases/BasicUsage.rst +++ b/doc/userguide/src/ExecutingTestCases/BasicUsage.rst @@ -311,7 +311,7 @@ discussed in more detail in the section `Different output files`_. Return codes ~~~~~~~~~~~~ -Runner scripts communicate the overall test execution status to the +Runner scripts communicate the overall execution status to the system running them using return codes. When the execution starts successfully and no tests fail, the return code is zero. All possible return codes are explained in the table below. @@ -326,25 +326,28 @@ All possible return codes are explained in the table below. 1-249 Returned number of tests failed. 250 250 or more failures. 251 Help or version information printed. - 252 Invalid test data or command line options. - 253 Test execution stopped by user. + 252 Invalid data or command line option. + 253 Execution stopped by user. 255 Unexpected internal error. ======== ========================================== Return codes should always be easily available after the execution, which makes it easy to automatically determine the overall execution -status. For example, in bash shell the return code is in special -variable `$?`, and in Windows it is in `%ERRORLEVEL%` +status. For example, in the Bash shell the return code is in the +`$?` variable, and in Windows it is in the `%ERRORLEVEL%` variable. If you use some external tool for running tests, consult its documentation for how to get the return code. -The return code can be set to 0 even if there are failures using -the :option:`--NoStatusRC` command line option. This might be useful, for +The return code can be set to zero regardless the execution status by using +the :option:`--nostatusrc` command line option. This might be useful, for example, in continuous integration servers where post-processing of results -is needed before the overall status of test execution can be determined. +is needed before the overall status of execution can be determined. .. note:: Same return codes are also used with Rebot_. +.. note:: When `getting help and version information`_, the :option:`--nostatusrc` + option has an effect only with Robot Framework 7.4 and newer. + Errors and warnings during execution ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -449,20 +452,25 @@ arguments with a script:: Getting help and version information ------------------------------------ -Both when executing test cases and when post-processing outputs, it is possible +Both when executing tests and when post-processing outputs, it is possible to get command line help with the option :option:`--help (-h)`. -These help texts have a short general overview and -briefly explain the available command line options. +This help text provides version information, a short general introduction +and explanation of the available command line options. -All runner scripts also support getting the version information with +It is also possible to get just the version information with the option :option:`--version`. This information also contains Python version and the platform type:: $ robot --version - Robot Framework 7.0 (Python 3.12.1 on darwin) + Robot Framework 7.4 (Python 3.14.0 on linux) C:\>rebot --version - Rebot 6.1.1 (Python 3.11.0 on win32) + Rebot 7.3.1 (Python 3.13.7 on win32) + +When help or version information is printed to the console, the execution +exits with a special `return code`_ 251 by default. Starting from Robot +Framework 7.4, the return code can be changed to zero by using the +:option:`--nostatusrc` option like `robot --version --nostatusrc`. .. _start-up script: .. _start-up scripts: diff --git a/src/robot/errors.py b/src/robot/errors.py index 6b94fb94f79..3a742a15198 100644 --- a/src/robot/errors.py +++ b/src/robot/errors.py @@ -119,6 +119,10 @@ def keyword_timeout(self): class Information(RobotError): """Used by argument parser with --help or --version.""" + def __init__(self, message: str, status_rc: bool = True): + super().__init__(message) + self.rc = INFO_PRINTED if status_rc else 0 + class ExecutionStatus(RobotError): """Base class for exceptions communicating status in test execution.""" diff --git a/src/robot/utils/application.py b/src/robot/utils/application.py index fd66b3deeab..5f54a149499 100644 --- a/src/robot/utils/application.py +++ b/src/robot/utils/application.py @@ -16,7 +16,7 @@ import sys from robot.errors import ( - DATA_ERROR, DataError, FRAMEWORK_ERROR, INFO_PRINTED, Information, STOPPED_BY_USER + DATA_ERROR, DataError, FRAMEWORK_ERROR, Information, STOPPED_BY_USER ) from .argumentparser import ArgumentParser @@ -69,8 +69,8 @@ def console(self, msg): def _parse_arguments(self, cli_args): try: options, arguments = self.parse_arguments(cli_args) - except Information as msg: - self._report_info(msg.message) + except Information as info: + self._report_info(info) except DataError as err: self._report_error(err.message, help=True, exit=True) else: @@ -107,9 +107,9 @@ def _execute(self, arguments, options): else: return rc or 0 - def _report_info(self, message): - self.console(message) - self._exit(INFO_PRINTED) + def _report_info(self, info): + self.console(info.message) + self._exit(info.rc) def _report_error( self, diff --git a/src/robot/utils/argumentparser.py b/src/robot/utils/argumentparser.py index 877f850e662..2d1dc84c146 100644 --- a/src/robot/utils/argumentparser.py +++ b/src/robot/utils/argumentparser.py @@ -169,20 +169,14 @@ def _get_env_options(self): return [] def _handle_special_options(self, opts, args): - if self._auto_help and opts.get("help"): - self._raise_help() - if self._auto_version and opts.get("version"): - self._raise_version() - if self._auto_pythonpath and opts.get("pythonpath"): - sys.path = self._get_pythonpath(opts["pythonpath"]) + sys.path - for auto, opt in [ - (self._auto_help, "help"), - (self._auto_version, "version"), - (self._auto_pythonpath, "pythonpath"), - (self._auto_argumentfile, "argumentfile"), - ]: - if auto and opt in opts: - opts.pop(opt) + if self._auto_help and opts.pop("help", False): + self._raise_help(opts.get("statusrc")) + if self._auto_version and opts.pop("version", False): + self._raise_version(opts.get("statusrc")) + if self._auto_pythonpath: + sys.path = self._get_pythonpath(opts.pop("pythonpath", [])) + sys.path + if self._auto_argumentfile: + opts.pop("argumentfile", None) return opts, args def _parse_args(self, args): @@ -320,14 +314,18 @@ def _split_pythonpath(self, paths): ret.append(drive) return ret - def _raise_help(self): + def _raise_help(self, status_rc=True): usage = self._usage if self.version: usage = usage.replace("", self.version) - raise Information(usage) - - def _raise_version(self): - raise Information(f"{self.name} {self.version}") + if status_rc is None: + status_rc = True + raise Information(usage, status_rc) + + def _raise_version(self, status_rc=True): + if status_rc is None: + status_rc = True + raise Information(f"{self.name} {self.version}", status_rc) def _raise_option_multiple_times_in_usage(self, opt): raise FrameworkError(f"Option '{opt}' multiple times in usage") From ee7b885826a64289555449a4697e33dde9b5f4c0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Sep 2025 12:02:50 +0300 Subject: [PATCH 25/39] Bump actions/setup-python from 5.6.0 to 6.0.0 (#5493) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5.6.0 to 6.0.0. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v5.6.0...v6.0.0) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: 6.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/acceptance_tests_cpython.yml | 4 ++-- .github/workflows/acceptance_tests_cpython_pr.yml | 4 ++-- .github/workflows/unit_tests.yml | 2 +- .github/workflows/unit_tests_pr.yml | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/acceptance_tests_cpython.yml b/.github/workflows/acceptance_tests_cpython.yml index 40b4a1b1ed9..1cc661642b0 100644 --- a/.github/workflows/acceptance_tests_cpython.yml +++ b/.github/workflows/acceptance_tests_cpython.yml @@ -36,7 +36,7 @@ jobs: - uses: actions/checkout@v5 - name: Setup python for starting the tests - uses: actions/setup-python@v5.6.0 + uses: actions/setup-python@v6.0.0 with: python-version: '3.13' architecture: 'x64' @@ -50,7 +50,7 @@ jobs: if: runner.os != 'Windows' - name: Setup python ${{ matrix.python-version }} for running the tests - uses: actions/setup-python@v5.6.0 + uses: actions/setup-python@v6.0.0 with: python-version: ${{ matrix.python-version }} architecture: 'x64' diff --git a/.github/workflows/acceptance_tests_cpython_pr.yml b/.github/workflows/acceptance_tests_cpython_pr.yml index 4476b386c0c..2b7e146fac8 100644 --- a/.github/workflows/acceptance_tests_cpython_pr.yml +++ b/.github/workflows/acceptance_tests_cpython_pr.yml @@ -29,7 +29,7 @@ jobs: - uses: actions/checkout@v5 - name: Setup python for starting the tests - uses: actions/setup-python@v5.6.0 + uses: actions/setup-python@v6.0.0 with: python-version: '3.13' architecture: 'x64' @@ -43,7 +43,7 @@ jobs: if: runner.os != 'Windows' - name: Setup python ${{ matrix.python-version }} for running the tests - uses: actions/setup-python@v5.6.0 + uses: actions/setup-python@v6.0.0 with: python-version: ${{ matrix.python-version }} architecture: 'x64' diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 7359abd2395..6ce36165754 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -32,7 +32,7 @@ jobs: - uses: actions/checkout@v5 - name: Setup python ${{ matrix.python-version }} - uses: actions/setup-python@v5.6.0 + uses: actions/setup-python@v6.0.0 with: python-version: ${{ matrix.python-version }} architecture: 'x64' diff --git a/.github/workflows/unit_tests_pr.yml b/.github/workflows/unit_tests_pr.yml index 208c3d42a8f..a065ad8c0a3 100644 --- a/.github/workflows/unit_tests_pr.yml +++ b/.github/workflows/unit_tests_pr.yml @@ -24,7 +24,7 @@ jobs: - uses: actions/checkout@v5 - name: Setup python ${{ matrix.python-version }} - uses: actions/setup-python@v5.6.0 + uses: actions/setup-python@v6.0.0 with: python-version: ${{ matrix.python-version }} architecture: 'x64' From ce2f39649f7c435caa7495959c6dbbaaaae43d1b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Sep 2025 12:03:29 +0300 Subject: [PATCH 26/39] Bump actions/setup-node from 4 to 5 (#5494) Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 5. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/web_tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/web_tests.yml b/.github/workflows/web_tests.yml index 69d4606fb68..b6ede34836a 100644 --- a/.github/workflows/web_tests.yml +++ b/.github/workflows/web_tests.yml @@ -22,7 +22,7 @@ jobs: - uses: actions/checkout@v5 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: "16" - name: Run tests From 36dc874c5d220a0852b44397abbb78b48aa45ece Mon Sep 17 00:00:00 2001 From: Tatu Aalto <2665023+aaltat@users.noreply.github.com> Date: Tue, 9 Sep 2025 12:53:49 +0300 Subject: [PATCH 27/39] Initial `Secret` implementation This commit contains the initial implementation of the `Secret` type (issue #4537) provided by the PR #5449. Basic functionality works, but there are still some things to look at. See the aforementioned issue and/or PR for details. --- .../keywords/type_conversion/secret.robot | 96 ++++++++ .../keywords/type_conversion/secret.py | 37 +++ .../keywords/type_conversion/secret.robot | 213 ++++++++++++++++++ .../CreatingTestLibraries.rst | 207 +++++++++++++++++ src/robot/api/__init__.py | 1 + src/robot/api/types/__init__.py | 16 ++ src/robot/libdocpkg/standardtypes.py | 40 ++++ src/robot/running/arguments/typeconverters.py | 17 +- src/robot/running/arguments/typeinfo.py | 3 +- src/robot/utils/__init__.py | 1 + src/robot/utils/_secret.py | 37 +++ src/robot/variables/scopes.py | 6 +- src/robot/variables/tablesetter.py | 81 ++++++- 13 files changed, 743 insertions(+), 12 deletions(-) create mode 100644 atest/robot/keywords/type_conversion/secret.robot create mode 100644 atest/testdata/keywords/type_conversion/secret.py create mode 100644 atest/testdata/keywords/type_conversion/secret.robot create mode 100644 src/robot/api/types/__init__.py create mode 100644 src/robot/utils/_secret.py diff --git a/atest/robot/keywords/type_conversion/secret.robot b/atest/robot/keywords/type_conversion/secret.robot new file mode 100644 index 00000000000..916a2cc6b16 --- /dev/null +++ b/atest/robot/keywords/type_conversion/secret.robot @@ -0,0 +1,96 @@ +*** Settings *** +Resource atest_resource.robot + +Suite Setup Run Tests --variable "CLI: Secret:From command line" keywords/type_conversion/secret.robot + + +*** Test Cases *** +Command line + Check Test Case ${TESTNAME} + +Variable section: Scalar + Check Test Case ${TESTNAME} + +Variable section: List + Check Test Case ${TESTNAME} + +Variable section: Dict + Check Test Case ${TESTNAME} + +Variable section: Invalid syntax + Error In File + ... 0 keywords/type_conversion/secret.robot 28 + ... Setting variable '\&{DICT3: Secret}' failed: + ... Value '{'a': 'b'}' (DotDict) cannot be converted to dict[Any, Secret]: + ... Item 'a' must have type 'Secret', got string. + ... pattern=${False} + Error In File + ... 1 keywords/type_conversion/secret.robot 26 + ... Setting variable '\&{DICT_LITERAL: secret}' failed: + ... Value 'fails' must have type 'Secret', got string. + Error In File + ... 2 keywords/type_conversion/secret.robot 9 + ... Setting variable '\${FROM_LITERAL: Secret}' failed: + ... Value 'this fails' must have type 'Secret', got string. + Error In File + ... 3 keywords/type_conversion/secret.robot 22 + ... Setting variable '\@{LIST2: Secret}' failed: + ... Value '\@{LIST_NORMAL}' must have type 'Secret', got string. + Error In File + ... 4 keywords/type_conversion/secret.robot 23 + ... Setting variable '\@{LIST3: Secret}' failed: + ... Value '\@{LIST}' must have type 'Secret', got string. + Error In File + ... 5 keywords/type_conversion/secret.robot 20 + ... Setting variable '\@{LIST_LITERAL: secret}' failed: + ... Value 'this' must have type 'Secret', got string. + Error In File + ... 6 keywords/type_conversion/secret.robot 14 + ... Setting variable '\${NO_VAR: secret}' failed: + ... Value '=\${42}=' must have type 'Secret', got string. + +VAR: Env variable + Check Test Case ${TESTNAME} + +VAR: Join secret + Check Test Case ${TESTNAME} + +VAR: Broken variable + Check Test Case ${TESTNAME} + +Create: List + Check Test Case ${TESTNAME} + +Create: List by extending + Check Test Case ${TESTNAME} + +Create: List of dictionaries + Check Test Case ${TESTNAME} + +Create: Dictionary + Check Test Case ${TESTNAME} + +Return value: Library keyword + Check Test Case ${TESTNAME} + +Return value: User keyword + Check Test Case ${TESTNAME} + +User keyword: Receive not secret + Check Test Case ${TESTNAME} + +User keyword: Receive not secret var + Check Test Case ${TESTNAME} + +Library keyword + Check Test Case ${TESTNAME} + +Library keyword: not secret + Check Test Case ${TESTNAME} 1 + Check Test Case ${TESTNAME} 2 + +Library keyword: TypedDict + Check Test Case ${TESTNAME} + +Library keyword: List of secrets + Check Test Case ${TESTNAME} diff --git a/atest/testdata/keywords/type_conversion/secret.py b/atest/testdata/keywords/type_conversion/secret.py new file mode 100644 index 00000000000..d79ce8ad070 --- /dev/null +++ b/atest/testdata/keywords/type_conversion/secret.py @@ -0,0 +1,37 @@ +from typing import TypedDict + +from robot.api import Secret + + +class Credential(TypedDict): + username: str + password: Secret + + +def library_get_secret(value: str = "This is a secret") -> Secret: + return Secret(value) + + +def library_not_secret(): + return "This is a string, not a secret" + + +def library_receive_secret(secret: Secret) -> str: + return secret.value + + +def library_receive_credential(credential: Credential) -> str: + return ( + f"Username: {credential['username']}, Password: {credential['password'].value}" + ) + + +def library_list_of_secrets(secrets: "list[Secret]") -> str: + return ", ".join(secret.value for secret in secrets) + + +def get_variables(): + return { + "VAR_FILE": Secret("From variable file"), + "VAR_FILE_SECRET": Secret("This is a secret used in tests"), + } diff --git a/atest/testdata/keywords/type_conversion/secret.robot b/atest/testdata/keywords/type_conversion/secret.robot new file mode 100644 index 00000000000..93cf1246869 --- /dev/null +++ b/atest/testdata/keywords/type_conversion/secret.robot @@ -0,0 +1,213 @@ +*** Settings *** +Library Collections +Library OperatingSystem +Library secret.py +Variables secret.py + + +*** Variables *** +${FROM_LITERAL: Secret} this fails +${FROM_EXISTING: secret} ${VAR_FILE} +${FROM_JOIN1: secret} abc${VAR_FILE}efg +${FROM_JOIN2: secret} =${VAR_FILE}= +${FROM_JOIN3: secret} =${42}==${VAR_FILE}= +${NO_VAR: secret} =${42}= +${FROM_ENV: SECRET} %{SECRET=kala} +${ENV_JOIN: SECRET} qwe%{SECRET=kala}rty +${FROM_ENV2: Secret} %{TEMPDIR} +${FROM_ENV3: Secret} =${84}=≈%{TEMPDIR}= +@{LIST: Secret} ${VAR_FILE} %{SECRET=kala} +@{LIST_LITERAL: secret} this fails ${VAR_FILE} %{SECRET=kala} +@{LIST_NORMAL} a b c +@{LIST2: Secret} @{LIST_NORMAL} +@{LIST3: Secret} @{LIST} +&{DICT1: Secret} var_file=${VAR_FILE} env=%{SECRET=kala} joined==${VAR_FILE}= +&{DICT2: secret=secret} ${VAR_FILE}=%{SECRET=kala} +&{DICT_LITERAL: secret} this=fails ok=${VAR_FILE} +&{DICT_NORMAL} a=b +&{DICT3: Secret} &{DICT_NORMAL} +${SECRET: Secret} ${VAR_FILE_SECRET} + + +*** Test Cases *** +Command line + Should Be Equal ${CLI.value} From command line + +Variable section: Scalar + Should Be Equal ${FROM_EXISTING.value} From variable file + Should Be Equal ${FROM_ENV.value} kala + Should Be Equal ${FROM_ENV2.value} %{TEMPDIR} + Should Be Equal ${FROM_ENV3.value} =84=≈%{TEMPDIR}= + Should Be Equal ${FROM_JOIN1.value} abcFrom variable fileefg + Should Be Equal ${FROM_JOIN2.value} =From variable file= + Should Be Equal ${FROM_JOIN3.value} =42==From variable file= + Should Be Equal ${ENV_JOIN.value} qwekalarty + Variable Should Not Exist ${FROM_LITERAL} + Variable Should Not Exist ${NO_VAR} + +Variable section: List + Should Be Equal ${LIST[0].value} From variable file + Should Be Equal ${LIST[1].value} kala + Variable Should Not Exist ${LIST_LITERAL} + Variable Should Not Exist ${LIST2} + Variable Should Not Exist ${LIST3} + +Variable section: Dict + Should Be Equal ${DICT1.var_file.value} From variable file + Should Be Equal ${DICT1.env.value} kala + Should Be Equal ${DICT1.joined.value} =From variable file= + Should Be Equal ${{$DICT2[$VAR_FILE].value}} kala + Variable Should Not Exist ${DICT_LITERAL} + Variable Should Not Exist ${DICT3} + +VAR: Env variable + Set Environment Variable SECRET VALUE1 + VAR ${secret: secret} %{SECRET} + Should Be Equal ${secret.value} VALUE1 + VAR ${x} SECRET + Set Environment Variable SECRET VALUE2 + VAR ${secret: secret} %{${x}} + Should Be Equal ${secret.value} VALUE2 + VAR ${secret: secret} %{INLINE_SECRET=inline_secret} + Should Be Equal ${secret.value} inline_secret + +VAR: Join secret + [Documentation] FAIL + ... Setting variable '\${zz: secret}' failed: \ + ... Value '111\${y}222' must have type 'Secret', got string. + ${secret1} Library Get Secret 111 + ${secret2} Library Get Secret 222 + VAR ${x: secret} abc${secret1} + Should Be Equal ${x.value} abc111 + VAR ${y: int} 42 + VAR ${x: secret} ${secret2}${y} + Should Be Equal ${x.value} 22242 + VAR ${x: secret} ${secret1}${secret2} + Should Be Equal ${x.value} 111222 + VAR ${x: secret} -${secret1}--${secret2}--- + Should Be Equal ${x.value} -111--222--- + VAR ${x: secret} -${y}--${secret1}---${y}----${secret2}----- + Should Be Equal + ... ${x.value} + ... -42--111---42----222----- + Set Environment Variable SECRET VALUE10 + VAR ${secret: secret} 11%{SECRET}22 + Should Be Equal ${secret.value} 11VALUE1022 + VAR ${zz: secret} 111${y}222 + +VAR: Broken variable + [Documentation] FAIL + ... Setting variable '\${x: Secret}' failed: Variable '${borken' was not closed properly. + VAR ${x: Secret} ${borken + +Create: List + [Documentation] FAIL + ... Setting variable '\@{x: secret}' failed: \ + ... Value 'this' must have type 'Secret', got string. + VAR @{x: secret} ${SECRET} ${SECRET} + Should Be Equal ${x[0].value} This is a secret used in tests + Should Be Equal ${x[1].value} This is a secret used in tests + VAR @{x: int|secret} 22 ${SECRET} 44 + Should Be Equal ${x[0]} 22 type=int + Should Be Equal ${x[1].value} This is a secret used in tests + Should Be Equal ${x[2]} 44 type=int + VAR @{x: secret} ${SECRET} this fails + +Create: List by extending + VAR @{x: secret} ${SECRET} ${SECRET} + VAR @{x} @{x} @{x} + Length Should Be ${x} 4 + Should Be Equal ${x[0].value} This is a secret used in tests + Should Be Equal ${x[1].value} This is a secret used in tests + Should Be Equal ${x[2].value} This is a secret used in tests + Should Be Equal ${x[3].value} This is a secret used in tests + +Create: List of dictionaries + VAR &{dict1: secret} key1=${SECRET} key2=${SECRET} + VAR &{dict2: secret} key3=${SECRET} + VAR @{list} ${dict1} ${dict2} + Length Should Be ${list} 2 + FOR ${d} IN @{list} + Dictionaries Should Be Equal ${d} ${d} + END + +Create: Dictionary + [Documentation] FAIL + ... Setting variable '\&{x: secret}' failed: \ + ... Value 'fails' must have type 'Secret', got string. + VAR &{x: secret} key=${SECRET} + Should Be Equal ${x.key.value} This is a secret used in tests + VAR &{x: int=secret} 42=${SECRET} + Should Be Equal ${x[42].value} This is a secret used in tests + VAR &{x: secret} this=fails + +Return value: Library keyword + [Documentation] FAIL + ... ValueError: Return value must have type 'Secret', got string. + ${x} Library Get Secret + Should Be Equal ${x.value} This is a secret + ${x: Secret} Library Get Secret value of secret here + Should Be Equal ${x.value} value of secret here + ${x: secret} Library Not Secret + +Return value: User keyword + [Documentation] FAIL + ... ValueError: Return value must have type 'Secret', got string. + ${x} User Keyword: Return secret + Should Be Equal ${x.value} This is a secret + ${x: Secret} User Keyword: Return secret + Should Be Equal ${x.value} This is a secret + ${x: secret} User Keyword: Return string + +User keyword: Receive not secret + [Documentation] FAIL + ... ValueError: Argument 'secret' must have type 'Secret', got string. + User Keyword: Receive secret xxx ${None} + +User keyword: Receive not secret var + [Documentation] FAIL + ... ValueError: Argument 'secret' must have type 'Secret', got string. + VAR ${x} y + User Keyword: Receive secret ${x} ${None} + +Library keyword + User Keyword: Receive secret ${SECRET} This is a secret used in tests + +Library keyword: not secret 1 + [Documentation] FAIL + ... ValueError: Argument 'secret' must have type 'Secret', got string. + Library receive secret 111 + +Library keyword: not secret 2 + [Documentation] FAIL + ... ValueError: Argument 'secret' must have type 'Secret', got integer. + Library receive secret ${222} + +Library keyword: TypedDict + [Documentation] FAIL + ... ValueError: Argument 'credential' got value \ + ... '{'username': 'login@email.com', 'password': 'This fails'}' (DotDict) that cannot be converted to Credential: \ + ... Item 'password' must have type 'Secret', got string. + VAR &{credentials} username=login@email.com password=${SECRET} + ${data} Library Receive Credential ${credentials} + Should Be Equal ${data} Username: login@email.com, Password: This is a secret used in tests + VAR &{credentials} username=login@email.com password=This fails + Library Receive Credential ${credentials} + +Library keyword: List of secrets + VAR @{secrets: secret} ${SECRET} ${SECRET} + ${data} Library List Of Secrets ${secrets} + Should Be Equal ${data} This is a secret used in tests, This is a secret used in tests + + +*** Keywords *** +User Keyword: Receive secret + [Arguments] ${secret: secret} ${expected: str} + Should Be Equal ${secret.value} ${expected} + +User Keyword: Return secret + ${secret} Library Get Secret + RETURN ${secret} + +User Keyword: Return string + RETURN This is a string diff --git a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst index b414f82e529..bbd961507b6 100644 --- a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst +++ b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst @@ -1428,6 +1428,16 @@ Other types cause conversion failures. | | | | | used earlier. | | | | | | | | | `{'width': 1600, 'enabled': True}` | +--------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ + | Secret_ | | | | Container to store secret data in variables. The Secret class | .. sourcecode:: python | + | | | | | instance stores the data in `value` attribute and `__str__` | | + | | | | | method is used to mask the real value of the secret. This | from robot.api import Secret | + | | | | | prevents the value from being logged by Robot Framework in the | | + | | | | | output files. Please note that libraries or other tools might | def login(token: Secret): | + | | | | | log the value in some other way, so `Secret` does does not | do_something(token.value) | + | | | | | guarantee to hide secret in all possible ways. | | + | | | | | | | + | | | | | New in Robot Framework 7.4. | | + +--------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ .. note:: Starting from Robot Framework 5.0, types that have a converted are automatically shown in Libdoc_ outputs. @@ -1436,6 +1446,13 @@ Other types cause conversion failures. `None`. That support has been removed and `None` conversion is only done if an argument has `None` as an explicit type or as a default value. +.. note:: Secret does not prevent libraries or other tools to log the value in + some way. Therefore `Secret` does does not guaranteed to hide secret + in all possible ways. Example, if Robot Framework log level is set to + DEBUG and SeleniumLibrary is used, then password is visible in the + log.html file, because selenium will log the all communication between + selenium and webdriver (like chromedriver.) + .. _Any: https://docs.python.org/library/typing.html#typing.Any .. _bool: https://docs.python.org/library/functions.html#bool .. _int: https://docs.python.org/library/functions.html#int @@ -1467,11 +1484,201 @@ Other types cause conversion failures. .. _abc.Set: https://docs.python.org/library/collections.abc.html#collections.abc.Set .. _frozenset: https://docs.python.org/library/stdtypes.html#frozenset .. _TypedDict: https://docs.python.org/library/typing.html#typing.TypedDict +.. _Secret: https://github.com/robotframework/robotframework/blob/master/src/robot/utils/secret.py .. _Container: https://docs.python.org/library/collections.abc.html#collections.abc.Container .. _typing: https://docs.python.org/library/typing.html .. _ISO 8601: https://en.wikipedia.org/wiki/ISO_8601 .. _ast.literal_eval: https://docs.python.org/library/ast.html#ast.literal_eval +Secret type +''''''''''' + +The `Secret` type has two purposes. First, it is used to prevent putting the +secret value in Robot Framework test data as plain text. Second, it is used to +hide secret from Robot Framework logs and reports. `Secret` is new feature in +Robot Framework 7.4 + +It is possible to use the `Secret` type to store secret data in variables. The +`Secret` class is defined in the `robot.utils.secret` module and it stores the +data in it's `value` attribute, where consumers of the `Secret` type must read +the value. The `__str__` method of the `Secret` class is used to mask the real +value of the secret, which prevents the value from being logged by Robot +Framework in the output files. However, please note that at some point +libraries or other tools might need to pass the secret as plain text and those +libraries or tools might log the value in some way as clear text, therefore +using `Secret` does not guarantee to hide the secret in all possible scenarios. + +The second aim of the `Secret` type is not to store value, like password or +token, as a plaint text in Robot Framework test data. Therefore creation of +`Secret` type variable is different from other types. The normal variable +types can be created from anywhere, example in variable table, but Secret type +can not be created directly in the Robot Framework test data. With the exception +of environment variables, which can be used to create secrets also in Robot +Framework test data. To create a Secret type of variable, there are four main +ways to create it. + +1) Secret can be created from command line +2) Secret can be created from environment variable in test data +3) Secret can be returned from a library keyword +4) Secret can be created in a variable file +5) Secret can be catenated + + +Creating Secret from command line +''''''''''''''''''''''''''''''''' + +The easiest way to create secrets is to use the :option:`--variable` command line option +with `Secret` type definition when running Robot Framework:: + + $ --variable "TOKEN: Secret:1234567890" + +This creates a variable named `${TOKEN}` which is of type `Secret` and has the value +`1234567890`. + +Create Secret from environment variable +''''''''''''''''''''''''''''''''''''''' + +`Secret` can be read from environment variable in Robot Framework test data in +example following ways. + +.. sourcecode:: robotframework + + *** Variables *** + ${TOKEN: Secret} %{ACCESSTOKEN} + + *** Test Cases *** + Example + VAR ${password: secret} %{USERPASSWORD} + +In the variable section, the `${TOKEN}` variable is created from the environment +variable `ACCESSTOKEN`. In the Example test, `${password}` variable is created +from the environment variable `USERPASSWORD`. + +Secret can be returned from a library keyword +''''''''''''''''''''''''''''''''''''''''''''' + +`Secret` can be returned from a keyword. The keyword must return the `Secret` type +and the value can be read from the `value` attribute of the `Secret` object. + +.. sourcecode:: robotframework + + *** Test Cases *** + Use JWT token + ${jwt_token: Secret} = Get JWT Token + ... + +If the keyword `Get JWT Token` returns a `Secret` type, the `${jwt_token}` variable +will be of type `Secret`. But if the keyword returns example string or some other +type, the variable assignment will fail with an error. + +Secret can be created in a variable file +'''''''''''''''''''''''''''''''''''''''' + +Variables with `Secret` type can be created in a Python `variable files`_. +The following example creates a variable `${TOKEN}` of type `Secret` with the value +that is read from environment variable `TOKEN`. + +.. sourcecode:: python + + import os + + from robot.utils import Secret + + TOKEN = Secret(os.environ['TOKEN']) + +Secret can be catenated +''''''''''''''''''''''' + +`Secret` type can be catenated with other `Secret` types or with other types +variables or strings. This creates a new `Secret` type with the concatenated +value. + +.. sourcecode:: robotframework + + *** Test Cases *** + Use JWT token with pin + ${jwt_token: Secret} = Get JWT Token + VAR ${token1: Secret} 1234${jwt_token} + VAR ${token2: Secret} ${1234}${jwt_token} + + Two part Token + ${part1: Secret} = Get First Part + ${part2: Secret} = Get Second Part + VAR ${token: Secret} ${part1}${part2} + +In the first test case, the `${jwt_token}` variable is of type `Secret` and it is +catenated with string `1234` to create a new `Secret` type variable `${token1}`. +The `${token2}` variable is created by concatenating the integer `1234` with the +`${jwt_token}` variable. Both `${token1}` and `${token2}` are of type `Secret`. +In the second test case, two `Secret` type variables `${part1}` and `${part2}` +are catenated to create a new `Secret` type variable `${token}`. + +Using Secret type in type hints +''''''''''''''''''''''''''''''' + +`Secret` type can be used in keywords argument hints like any other type. The +`Secret` type can be used both user keyword and library keywords. In other +types Robot Framework automatically converts the argument to the specified type, +but for `Secret` type the value is not converted. If value is not `Secret` type, +the keyword will fail with an error. + +.. sourcecode:: python + + from robot.utils import Secret + + def login_to_sut(user: str, token: Secret): + # Login somewhere with the token + SUT.login(user, token.value) + +.. sourcecode:: robotframework + + *** Keywords *** + Login + [Arguments] ${user: str} ${token: Secret} + Login To Sut ${user} ${token} + +In the library keyword example above, the `token` argument must always receive +value which type is `Secret`. If type is something else keyword will fail. Same +logic applies to user keywords, in the example above the `token` argument must +always receive value which type is `Secret`. If the type is something else, the +keyword will also fail. + +`Secret` type can be used in lists or dictionaries as a type hint. Like in the +keyword examples above, the value must be of type `Secret` or declaring the +variable will fail. + +.. sourcecode:: robotframework + + *** Test Cases *** + List and dictionary + VAR @{list: secret} ${TOKEN} ${PASSWORD} + VAR &{dict: secret} username=user password=${SECRET} + +The above example declares a list variable `${list}` and a dictionary variable +`${dict}`. The list variable contains two `Secret` type values, `${TOKEN}` and +`${PASSWORD}`. The dictionary variable contains two values key pairs and the +`password` key contains `Secret` type value. + +.. sourcecode:: python + + from typing import TypedDict + + from robot.utils import Secret + + + class Credential(TypedDict): + username: str + password: Secret + + def login(credentials: Credential): + # Login somewhere with the credentials + SUT.login(credentials['username'], credentials['password'].value) + +Using `Secret` type in complex type hints works similarly as with other types. +The library keyword `login` uses type hint `Credential` which is a `TypedDict`_ +that contains a `Secret` type for the password key. + + Specifying multiple possible types '''''''''''''''''''''''''''''''''' diff --git a/src/robot/api/__init__.py b/src/robot/api/__init__.py index 5f5a6de3e9d..a6e41df52ef 100644 --- a/src/robot/api/__init__.py +++ b/src/robot/api/__init__.py @@ -103,6 +103,7 @@ TestSuiteBuilder as TestSuiteBuilder, TypeInfo as TypeInfo, ) +from robot.utils import Secret as Secret from .exceptions import ( ContinuableFailure as ContinuableFailure, diff --git a/src/robot/api/types/__init__.py b/src/robot/api/types/__init__.py new file mode 100644 index 00000000000..63c36f49628 --- /dev/null +++ b/src/robot/api/types/__init__.py @@ -0,0 +1,16 @@ +# Copyright 2008-2015 Nokia Networks +# Copyright 2016- Robot Framework Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from robot.utils import Secret as Secret diff --git a/src/robot/libdocpkg/standardtypes.py b/src/robot/libdocpkg/standardtypes.py index 392bcc2553e..e3ab413d916 100644 --- a/src/robot/libdocpkg/standardtypes.py +++ b/src/robot/libdocpkg/standardtypes.py @@ -23,6 +23,8 @@ except ImportError: # Python < 3.10 NoneType = type(None) +from robot.utils import Secret + STANDARD_TYPE_DOCS = { Any: """\ Any value is accepted. No conversion is done. @@ -198,6 +200,44 @@ Strings are case, space, underscore and hyphen insensitive, but exact matches have precedence over normalized matches. +""", + Secret: """\ +The Secret type has two purposes. First, it is used to +prevent putting the secret value in Robot Framework +data as plain text. Second, it is used to hide secret +from Robot Framework logs and reports. + +Usage of the Secret type does not fully prevent the value +being from logged in libraries that uses the Secret type, +because libraries will need pass the value as plain text +to other libraries and system commands which may log the +secret value. Also user may access the Secret type +:attr:`value` attribute to get the actual secret and this can +reveal the value in the logs. The only protection +that is provided is the encapsulation of the value in a +Secret class which prevents the value being directly logged in +Robot Framework logs and reports. + +The creation of Secret is more restricted than normal variable +types. Normal variable types can be created from anywhere, +example in the variables section, but Secret type can not be +created directly in the Robot Framework data. The exception +to the rule is that an environment variable can be used to +create secrets directly in the Robot Framework data. + +There are several ways to create Secret variables: +- Secret can be created from command line +- Secret can be returned from a library keyword +- Secret can be created in a variable file +- Secret can be created from environment variable. + +The Secret type can be used in user keywords argument types, +like any other standard +[https://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#supported-conversions|supported conversion] +types to enforce that the variable is actually a Secret type. +But to exception to other supported conversion types, if the +variable type is not Secret, an error is raised when keyword +is called. """, } diff --git a/src/robot/running/arguments/typeconverters.py b/src/robot/running/arguments/typeconverters.py index a91b0cc862d..674b36b842a 100644 --- a/src/robot/running/arguments/typeconverters.py +++ b/src/robot/running/arguments/typeconverters.py @@ -27,7 +27,7 @@ from robot.conf import Languages from robot.libraries.DateTime import convert_date, convert_time from robot.utils import ( - eq, get_error_message, plural_or_not as s, safe_str, seq2str, type_name + eq, get_error_message, plural_or_not as s, safe_str, Secret, seq2str, type_name ) if TYPE_CHECKING: @@ -794,6 +794,21 @@ def _convert(self, value): raise ValueError +@TypeConverter.register +class SecretConverter(TypeConverter): + type = Secret + + def _convert(self, value): + raise ValueError + + def _handle_error(self, value, name, kind, error=None): + kind = kind.capitalize() if kind.islower() else kind + typ = type_name(value) + if name is None: + raise ValueError(f"{kind} must have type 'Secret', got {typ}.") + raise ValueError(f"{kind} '{name}' must have type 'Secret', got {typ}.") + + class CustomConverter(TypeConverter): def __init__( diff --git a/src/robot/running/arguments/typeinfo.py b/src/robot/running/arguments/typeinfo.py index 43cbe545e96..7f064ee5f67 100644 --- a/src/robot/running/arguments/typeinfo.py +++ b/src/robot/running/arguments/typeinfo.py @@ -38,7 +38,7 @@ from robot.conf import Languages, LanguagesLike from robot.errors import DataError from robot.utils import ( - is_union, NOT_SET, plural_or_not as s, setter, SetterAwareType, type_name, + is_union, NOT_SET, plural_or_not as s, Secret, setter, SetterAwareType, type_name, type_repr, typeddict_types ) from robot.variables import search_variable, VariableMatch @@ -80,6 +80,7 @@ "frozenset": frozenset, "union": Union, "literal": Literal, + "secret": Secret, } LITERAL_TYPES = (int, str, bytes, bool, Enum, type(None)) diff --git a/src/robot/utils/__init__.py b/src/robot/utils/__init__.py index 9e619bd12ac..33420ea1699 100644 --- a/src/robot/utils/__init__.py +++ b/src/robot/utils/__init__.py @@ -35,6 +35,7 @@ import warnings +from ._secret import Secret as Secret from .application import Application as Application from .argumentparser import ( ArgumentParser as ArgumentParser, diff --git a/src/robot/utils/_secret.py b/src/robot/utils/_secret.py new file mode 100644 index 00000000000..63968c83f47 --- /dev/null +++ b/src/robot/utils/_secret.py @@ -0,0 +1,37 @@ +# Copyright 2008-2015 Nokia Networks +# Copyright 2016- Robot Framework Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +# FIXME: Consider moving this to robot.api +class Secret: + """Represent a secret value that should not be logged or displayed in plain text. + + This class is used to encapsulate sensitive information, such as passwords or + API keys, ensuring that when the value is logged, it is not exposed by + Robot Framework by its original value. Please note when libraries or + tools use this class, they should ensure that the value is not logged + or displayed in any way that could compromise its confidentiality. In some + cases, this is not fully possible, example selenium or Playwright might + still reveal the value in log messages or other outputs. + + Libraries or tools using the Secret class can use the value attribute to + access the actual secret value when necessary. + """ + + def __init__(self, value: str): + self.value = value + + def __str__(self) -> str: + return f"{type(self).__name__}(value=)" diff --git a/src/robot/variables/scopes.py b/src/robot/variables/scopes.py index bd302bab5be..6452a7730bf 100644 --- a/src/robot/variables/scopes.py +++ b/src/robot/variables/scopes.py @@ -211,10 +211,14 @@ def _convert_cli_variable(self, name, typ, value): info = TypeInfo.from_variable(var) except DataError as err: raise DataError(f"Invalid command line variable '{var}': {err}") + from robot.api import Secret + + if info.type is Secret: + return Secret(value) try: return info.convert(value, var, kind="Command line variable") except ValueError as err: - raise DataError(err) + raise DataError(str(err)) def _set_built_in_variables(self, settings): options = DotDict( diff --git a/src/robot/variables/tablesetter.py b/src/robot/variables/tablesetter.py index 00b21b81658..63b36077d08 100644 --- a/src/robot/variables/tablesetter.py +++ b/src/robot/variables/tablesetter.py @@ -16,7 +16,8 @@ from typing import Any, Callable, Sequence, TYPE_CHECKING from robot.errors import DataError -from robot.utils import DotDict, split_from_equals +from robot.utils import DotDict, Secret, split_from_equals, type_name +from robot.utils.unic import safe_str from .resolvable import Resolvable from .search import is_dict_variable, is_list_variable, search_variable @@ -111,6 +112,35 @@ def resolve(self, variables) -> Any: def _replace_variables(self, variables) -> Any: raise NotImplementedError + def _handle_secrets(self, value, replace_scalar, typ=None): + match = search_variable(value, identifiers="$%") + if match.is_variable(): + secret = replace_scalar(match.match) + if match.identifier == "%": + secret = Secret(secret) + else: + secret = self._handle_embedded_secrets(match, replace_scalar) + if isinstance(secret, Secret): + return secret + typ = type_name(value) + raise DataError(f"Value '{value}' must have type 'Secret', got {typ}.") + + def _handle_embedded_secrets(self, match, replace_scalar): + parts = [] + secret_seen = False + while match: + secret = replace_scalar(match.match) + if match.identifier == "%": + secret_seen = True + elif isinstance(secret, Secret): + secret_seen = True + secret = secret.value + parts.extend([match.before, secret]) + match = search_variable(match.after, identifiers="$%") + parts.append(match.string) + value = "".join(safe_str(p) for p in parts) + return Secret(value) if secret_seen else value + def _convert(self, value, type_): from robot.running import TypeInfo @@ -126,6 +156,10 @@ def report_error(self, error): else: raise DataError(f"Error reporter not set. Reported error was: {error}") + def _is_secret_type(self, typ=None) -> bool: + typ = typ or self.type + return bool(typ and typ.title() == "Secret") + class ScalarVariableResolver(VariableResolver): @@ -152,7 +186,12 @@ def _get_value_and_separator(self, value, separator): def _replace_variables(self, variables): value, separator = self.value, self.separator if self._is_single_value(value, separator): - return variables.replace_scalar(value[0]) + replace_scalar = variables.replace_scalar + if self._is_secret_type(): + value = self._handle_secrets(value[0], replace_scalar) + else: + value = value[0] + return replace_scalar(value) if separator is None: separator = " " else: @@ -167,7 +206,15 @@ def _is_single_value(self, value, separator): class ListVariableResolver(VariableResolver): def _replace_variables(self, variables): - return variables.replace_list(self.value) + if self._is_secret_type(): + replace_scalar = variables.replace_scalar + value = [ + self._handle_secrets(value, replace_scalar) for value in self.value + ] + value = tuple(value) + else: + value = self.value + return variables.replace_list(value) def _convert(self, value, type_): return super()._convert(value, f"list[{type_}]") @@ -198,13 +245,29 @@ def _replace_variables(self, variables): raise DataError(f"Creating dictionary variable failed: {err}") def _yield_replaced(self, values, replace_scalar): + if not self.type: + k_type = v_type = None + elif "=" in self.type: + k_type, v_type = self.type.split("=", 1) + else: + k_type, v_type = "Any", self.type for item in values: - if isinstance(item, tuple): - key, values = item - yield replace_scalar(key), replace_scalar(values) - else: - yield from replace_scalar(item).items() + yield from self.__yield_replaced(item, k_type, v_type, replace_scalar) + + def __yield_replaced(self, item, k_type, v_type, replace_scalar): + if isinstance(item, tuple): + key, value = item + if self._is_secret_type(k_type): + key = self._handle_secrets(key, replace_scalar, k_type) + if self._is_secret_type(v_type): + value = self._handle_secrets(value, replace_scalar, v_type) + yield ( + replace_scalar(key), + replace_scalar(value), + ) + else: + yield from replace_scalar(item).items() def _convert(self, value, type_): - k_type, v_type = self.type.split("=", 1) if "=" in type_ else ("Any", type_) + k_type, v_type = type_.split("=", 1) if "=" in type_ else ("Any", type_) return super()._convert(value, f"dict[{k_type}, {v_type}]") From 850185a3857c35feeaf2256d81e15405c6fe8484 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Tue, 9 Sep 2025 12:57:23 +0300 Subject: [PATCH 28/39] Cleanup and performance optimization --- src/robot/libraries/String.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/robot/libraries/String.py b/src/robot/libraries/String.py index 8135c10260e..69362af7f56 100644 --- a/src/robot/libraries/String.py +++ b/src/robot/libraries/String.py @@ -125,10 +125,10 @@ def convert_to_title_case(self, string, exclude=None): exclude = [e.strip() for e in exclude.split(",")] elif not exclude: exclude = [] - exclude = [re.compile(f"^{e}$") for e in exclude] + exclude = [re.compile(e) for e in exclude] def title(word): - if any(e.match(word) for e in exclude) or not word.islower(): + if any(e.fullmatch(word) for e in exclude) or not word.islower(): return word for index, char in enumerate(word): if char.isalpha(): @@ -136,7 +136,7 @@ def title(word): return word tokens = re.split(r"(\s+)", string, flags=re.UNICODE) - return "".join(title(token) for token in tokens) + return "".join(title(t) if i % 2 == 0 else t for i, t in enumerate(tokens)) def encode_string_to_bytes(self, string, encoding, errors="strict"): """Encodes the given ``string`` to bytes using the given ``encoding``. From b11eadb315da8daa3553d392537e70d53b25a3c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Tue, 9 Sep 2025 13:35:44 +0300 Subject: [PATCH 29/39] Cleanup related to issue #4537 and PR #5449 --- .../keywords/type_conversion/secret.py | 2 +- src/robot/api/__init__.py | 1 - src/robot/variables/scopes.py | 3 +- src/robot/variables/tablesetter.py | 71 +++++++++---------- 4 files changed, 35 insertions(+), 42 deletions(-) diff --git a/atest/testdata/keywords/type_conversion/secret.py b/atest/testdata/keywords/type_conversion/secret.py index d79ce8ad070..82d89a3d66d 100644 --- a/atest/testdata/keywords/type_conversion/secret.py +++ b/atest/testdata/keywords/type_conversion/secret.py @@ -1,6 +1,6 @@ from typing import TypedDict -from robot.api import Secret +from robot.api.types import Secret class Credential(TypedDict): diff --git a/src/robot/api/__init__.py b/src/robot/api/__init__.py index a6e41df52ef..5f5a6de3e9d 100644 --- a/src/robot/api/__init__.py +++ b/src/robot/api/__init__.py @@ -103,7 +103,6 @@ TestSuiteBuilder as TestSuiteBuilder, TypeInfo as TypeInfo, ) -from robot.utils import Secret as Secret from .exceptions import ( ContinuableFailure as ContinuableFailure, diff --git a/src/robot/variables/scopes.py b/src/robot/variables/scopes.py index 6452a7730bf..711c3779498 100644 --- a/src/robot/variables/scopes.py +++ b/src/robot/variables/scopes.py @@ -204,6 +204,7 @@ def _set_cli_variables(self, settings): self[f"${{{name}}}"] = value def _convert_cli_variable(self, name, typ, value): + from robot.api.types import Secret from robot.running import TypeInfo var = f"${{{name}: {typ}}}" @@ -211,8 +212,6 @@ def _convert_cli_variable(self, name, typ, value): info = TypeInfo.from_variable(var) except DataError as err: raise DataError(f"Invalid command line variable '{var}': {err}") - from robot.api import Secret - if info.type is Secret: return Secret(value) try: diff --git a/src/robot/variables/tablesetter.py b/src/robot/variables/tablesetter.py index 63b36077d08..1dda347fc5b 100644 --- a/src/robot/variables/tablesetter.py +++ b/src/robot/variables/tablesetter.py @@ -16,8 +16,7 @@ from typing import Any, Callable, Sequence, TYPE_CHECKING from robot.errors import DataError -from robot.utils import DotDict, Secret, split_from_equals, type_name -from robot.utils.unic import safe_str +from robot.utils import DotDict, safe_str, Secret, split_from_equals, type_name from .resolvable import Resolvable from .search import is_dict_variable, is_list_variable, search_variable @@ -112,7 +111,7 @@ def resolve(self, variables) -> Any: def _replace_variables(self, variables) -> Any: raise NotImplementedError - def _handle_secrets(self, value, replace_scalar, typ=None): + def _handle_secrets(self, value, replace_scalar): match = search_variable(value, identifiers="$%") if match.is_variable(): secret = replace_scalar(match.match) @@ -120,10 +119,11 @@ def _handle_secrets(self, value, replace_scalar, typ=None): secret = Secret(secret) else: secret = self._handle_embedded_secrets(match, replace_scalar) - if isinstance(secret, Secret): - return secret - typ = type_name(value) - raise DataError(f"Value '{value}' must have type 'Secret', got {typ}.") + if not isinstance(secret, Secret): + raise DataError( + f"Value '{value}' must have type 'Secret', got {type_name(value)}." + ) + return secret def _handle_embedded_secrets(self, match, replace_scalar): parts = [] @@ -186,12 +186,9 @@ def _get_value_and_separator(self, value, separator): def _replace_variables(self, variables): value, separator = self.value, self.separator if self._is_single_value(value, separator): - replace_scalar = variables.replace_scalar if self._is_secret_type(): - value = self._handle_secrets(value[0], replace_scalar) - else: - value = value[0] - return replace_scalar(value) + return self._handle_secrets(value[0], variables.replace_scalar) + return variables.replace_scalar(value[0]) if separator is None: separator = " " else: @@ -207,14 +204,11 @@ class ListVariableResolver(VariableResolver): def _replace_variables(self, variables): if self._is_secret_type(): - replace_scalar = variables.replace_scalar - value = [ - self._handle_secrets(value, replace_scalar) for value in self.value + return [ + self._handle_secrets(value, variables.replace_scalar) + for value in self.value ] - value = tuple(value) - else: - value = self.value - return variables.replace_list(value) + return variables.replace_list(self.value) def _convert(self, value, type_): return super()._convert(value, f"list[{type_}]") @@ -246,27 +240,28 @@ def _replace_variables(self, variables): def _yield_replaced(self, values, replace_scalar): if not self.type: - k_type = v_type = None - elif "=" in self.type: - k_type, v_type = self.type.split("=", 1) + secret_key = secret_value = False + elif "=" not in self.type: + secret_key = False + secret_value = self._is_secret_type(self.type) else: - k_type, v_type = "Any", self.type + kt, vt = self.type.split("=", 1) + secret_key = self._is_secret_type(kt) + secret_value = self._is_secret_type(vt) for item in values: - yield from self.__yield_replaced(item, k_type, v_type, replace_scalar) - - def __yield_replaced(self, item, k_type, v_type, replace_scalar): - if isinstance(item, tuple): - key, value = item - if self._is_secret_type(k_type): - key = self._handle_secrets(key, replace_scalar, k_type) - if self._is_secret_type(v_type): - value = self._handle_secrets(value, replace_scalar, v_type) - yield ( - replace_scalar(key), - replace_scalar(value), - ) - else: - yield from replace_scalar(item).items() + if isinstance(item, tuple): + key, value = item + if secret_key: + key = self._handle_secrets(key, replace_scalar) + else: + key = replace_scalar(key) + if secret_value: + value = self._handle_secrets(value, replace_scalar) + else: + value = replace_scalar(value) + yield key, value + else: + yield from replace_scalar(item).items() def _convert(self, value, type_): k_type, v_type = type_.split("=", 1) if "=" in type_ else ("Any", type_) From c89833b0b54a1a4d921aace4787cb96d0e2ee936 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Tue, 9 Sep 2025 22:15:35 +0300 Subject: [PATCH 30/39] Fixes and enhancements to Secret. - Fix creating secret lists with list variables in values. - Explicit tests for secret dicts with dict variables in values. - Fix handlig escaped content when handling embedded secrets. - Add `Secret.__repr__`. - Report the type of the resolved value in errors. See issue #4537 and PR #5449. --- .../keywords/type_conversion/secret.robot | 80 +++--- .../keywords/type_conversion/secret.py | 5 +- .../keywords/type_conversion/secret.robot | 240 ++++++++++-------- src/robot/utils/_secret.py | 3 + src/robot/variables/tablesetter.py | 23 +- 5 files changed, 194 insertions(+), 157 deletions(-) diff --git a/atest/robot/keywords/type_conversion/secret.robot b/atest/robot/keywords/type_conversion/secret.robot index 916a2cc6b16..241f5837ee2 100644 --- a/atest/robot/keywords/type_conversion/secret.robot +++ b/atest/robot/keywords/type_conversion/secret.robot @@ -8,66 +8,78 @@ Suite Setup Run Tests --variable "CLI: Secret:From command line" keywo Command line Check Test Case ${TESTNAME} -Variable section: Scalar +Variable section: Based on existing variable Check Test Case ${TESTNAME} -Variable section: List +Variable section: Based on environment variable Check Test Case ${TESTNAME} -Variable section: Dict +Variable section: Joined Check Test Case ${TESTNAME} -Variable section: Invalid syntax - Error In File - ... 0 keywords/type_conversion/secret.robot 28 - ... Setting variable '\&{DICT3: Secret}' failed: - ... Value '{'a': 'b'}' (DotDict) cannot be converted to dict[Any, Secret]: - ... Item 'a' must have type 'Secret', got string. - ... pattern=${False} - Error In File - ... 1 keywords/type_conversion/secret.robot 26 - ... Setting variable '\&{DICT_LITERAL: secret}' failed: - ... Value 'fails' must have type 'Secret', got string. +Variable section: Scalar fail + Check Test Case ${TESTNAME} Error In File - ... 2 keywords/type_conversion/secret.robot 9 - ... Setting variable '\${FROM_LITERAL: Secret}' failed: + ... 6 keywords/type_conversion/secret.robot 11 + ... Setting variable '\${LITERAL: Secret}' failed: ... Value 'this fails' must have type 'Secret', got string. Error In File - ... 3 keywords/type_conversion/secret.robot 22 - ... Setting variable '\@{LIST2: Secret}' failed: - ... Value '\@{LIST_NORMAL}' must have type 'Secret', got string. + ... 0 keywords/type_conversion/secret.robot 12 + ... Setting variable '\${BAD: Secret}' failed: + ... Value '\${666}' must have type 'Secret', got integer. Error In File - ... 4 keywords/type_conversion/secret.robot 23 - ... Setting variable '\@{LIST3: Secret}' failed: - ... Value '\@{LIST}' must have type 'Secret', got string. + ... 3 keywords/type_conversion/secret.robot 16 + ... Setting variable '\${JOIN4: Secret}' failed: + ... Value 'this fails \${2}!' must have type 'Secret', got string. + +Variable section: List + Check Test Case ${TESTNAME} + +Variable section: List fail + Check Test Case ${TESTNAME} Error In File - ... 5 keywords/type_conversion/secret.robot 20 - ... Setting variable '\@{LIST_LITERAL: secret}' failed: + ... 4 keywords/type_conversion/secret.robot 19 + ... Setting variable '\@{LIST3: Secret}' failed: ... Value 'this' must have type 'Secret', got string. Error In File - ... 6 keywords/type_conversion/secret.robot 14 - ... Setting variable '\${NO_VAR: secret}' failed: - ... Value '=\${42}=' must have type 'Secret', got string. + ... 5 keywords/type_conversion/secret.robot 20 + ... Setting variable '\@{LIST4: Secret}' failed: + ... Value '[Secret(value=), Secret(value=), Secret(value=), 'this', 'fails', Secret(value=)]' (list) cannot be converted to list[Secret]: + ... Item '3' must have type 'Secret', got string. + ... pattern=False -VAR: Env variable +Variable section: Dict Check Test Case ${TESTNAME} -VAR: Join secret +Variable section: Dict fail Check Test Case ${TESTNAME} + Error In File + ... 1 keywords/type_conversion/secret.robot 24 + ... Setting variable '\&{DICT4: Secret}' failed: + ... Value 'fails' must have type 'Secret', got string. + Error In File + ... 2 keywords/type_conversion/secret.robot 25 + ... Setting variable '\&{DICT5: Secret}' failed: + ... Value '{'ok': Secret(value=), 'var': Secret(value=), 'env': Secret(value=), 'join': Secret(value=), 'this': 'fails'}' (DotDict) cannot be converted to dict[Any, Secret]: + ... Item 'this' must have type 'Secret', got string. + ... pattern=False -VAR: Broken variable +VAR: Based on existing variable Check Test Case ${TESTNAME} -Create: List +VAR: Based on environment variable Check Test Case ${TESTNAME} -Create: List by extending +VAR: Joined + Check Test Case ${TESTNAME} + +VAR: Broken variable Check Test Case ${TESTNAME} -Create: List of dictionaries +VAR: List Check Test Case ${TESTNAME} -Create: Dictionary +Create: Dict Check Test Case ${TESTNAME} Return value: Library keyword diff --git a/atest/testdata/keywords/type_conversion/secret.py b/atest/testdata/keywords/type_conversion/secret.py index 82d89a3d66d..17d2bf92f2f 100644 --- a/atest/testdata/keywords/type_conversion/secret.py +++ b/atest/testdata/keywords/type_conversion/secret.py @@ -31,7 +31,4 @@ def library_list_of_secrets(secrets: "list[Secret]") -> str: def get_variables(): - return { - "VAR_FILE": Secret("From variable file"), - "VAR_FILE_SECRET": Secret("This is a secret used in tests"), - } + return {"VAR_FILE": Secret("Secret value")} diff --git a/atest/testdata/keywords/type_conversion/secret.robot b/atest/testdata/keywords/type_conversion/secret.robot index 93cf1246869..64856984b94 100644 --- a/atest/testdata/keywords/type_conversion/secret.robot +++ b/atest/testdata/keywords/type_conversion/secret.robot @@ -1,82 +1,115 @@ *** Settings *** -Library Collections -Library OperatingSystem -Library secret.py -Variables secret.py - +Library Collections +Library OperatingSystem +Library secret.py +Variables secret.py *** Variables *** -${FROM_LITERAL: Secret} this fails -${FROM_EXISTING: secret} ${VAR_FILE} -${FROM_JOIN1: secret} abc${VAR_FILE}efg -${FROM_JOIN2: secret} =${VAR_FILE}= -${FROM_JOIN3: secret} =${42}==${VAR_FILE}= -${NO_VAR: secret} =${42}= -${FROM_ENV: SECRET} %{SECRET=kala} -${ENV_JOIN: SECRET} qwe%{SECRET=kala}rty -${FROM_ENV2: Secret} %{TEMPDIR} -${FROM_ENV3: Secret} =${84}=≈%{TEMPDIR}= -@{LIST: Secret} ${VAR_FILE} %{SECRET=kala} -@{LIST_LITERAL: secret} this fails ${VAR_FILE} %{SECRET=kala} -@{LIST_NORMAL} a b c -@{LIST2: Secret} @{LIST_NORMAL} -@{LIST3: Secret} @{LIST} -&{DICT1: Secret} var_file=${VAR_FILE} env=%{SECRET=kala} joined==${VAR_FILE}= -&{DICT2: secret=secret} ${VAR_FILE}=%{SECRET=kala} -&{DICT_LITERAL: secret} this=fails ok=${VAR_FILE} -&{DICT_NORMAL} a=b -&{DICT3: Secret} &{DICT_NORMAL} -${SECRET: Secret} ${VAR_FILE_SECRET} - +${SECRET: Secret} ${VAR_FILE} +${ENV1: SECRET} %{TEMPDIR} +${ENV2: secret} %{NONEX=kala} +${LITERAL: Secret} this fails +${BAD: Secret} ${666} +${JOIN1: Secret} =${SECRET}= +${JOIN2: Secret} =\=\\=%{TEMPDIR}=\\=\=\${ESCAPED}= +${JOIN3: Secret} =${3}=${SECRET}= +${JOIN4: Secret} this fails ${2}! +@{LIST1: Secret} ${SECRET} %{TEMPDIR} =${SECRET}= +@{LIST2: Secret} ${SECRET} @{LIST1} @{EMPTY} +@{LIST3: Secret} this ${SECRET} fails +@{LIST4: Secret} @{LIST1} @{{["this", "fails"]}} ${SECRET} +&{DICT1: Secret} var=${SECRET} env=%{TEMPDIR} join==${SECRET}= +&{DICT2: Secret} ${2}=${SECRET} &{DICT1} &{EMPTY} +&{DICT3: Secret=Secret} %{TEMPDIR}=${SECRET} \=%{TEMPDIR}\===${SECRET}= +&{DICT4: Secret} ok=${SECRET} this=fails +&{DICT5: Secret} ok=${SECRET} &{DICT1} &{{{"this": "fails"}}} *** Test Cases *** Command line Should Be Equal ${CLI.value} From command line -Variable section: Scalar - Should Be Equal ${FROM_EXISTING.value} From variable file - Should Be Equal ${FROM_ENV.value} kala - Should Be Equal ${FROM_ENV2.value} %{TEMPDIR} - Should Be Equal ${FROM_ENV3.value} =84=≈%{TEMPDIR}= - Should Be Equal ${FROM_JOIN1.value} abcFrom variable fileefg - Should Be Equal ${FROM_JOIN2.value} =From variable file= - Should Be Equal ${FROM_JOIN3.value} =42==From variable file= - Should Be Equal ${ENV_JOIN.value} qwekalarty - Variable Should Not Exist ${FROM_LITERAL} - Variable Should Not Exist ${NO_VAR} +Variable section: Based on existing variable + Should Be Equal ${SECRET.value} Secret value + +Variable section: Based on environment variable + Should Be Equal ${ENV1.value} %{TEMPDIR} + Should Be Equal ${ENV2.value} kala + +Variable section: Joined + Should Be Equal ${JOIN1.value} =Secret value= + Should Be Equal ${JOIN2.value} ==\\=%{TEMPDIR}=\\==\${ESCAPED}= + Should Be Equal ${JOIN3.value} =3=Secret value= + +Variable section: Scalar fail + Variable Should Not Exist ${LITERAL} + Variable Should Not Exist ${JOIN4} Variable section: List - Should Be Equal ${LIST[0].value} From variable file - Should Be Equal ${LIST[1].value} kala - Variable Should Not Exist ${LIST_LITERAL} - Variable Should Not Exist ${LIST2} + Should Be Equal + ... ${{[item.value for item in $LIST1]}} + ... ["Secret value", "%{TEMPDIR}", "=Secret value="] + ... type=list + Should Be Equal + ... ${{[item.value for item in $LIST2]}} + ... ["Secret value", "Secret value", "%{TEMPDIR}", "=Secret value="] + ... type=list + +Variable section: List fail Variable Should Not Exist ${LIST3} + Variable Should Not Exist ${LIST4} Variable section: Dict - Should Be Equal ${DICT1.var_file.value} From variable file - Should Be Equal ${DICT1.env.value} kala - Should Be Equal ${DICT1.joined.value} =From variable file= - Should Be Equal ${{$DICT2[$VAR_FILE].value}} kala - Variable Should Not Exist ${DICT_LITERAL} - Variable Should Not Exist ${DICT3} - -VAR: Env variable + Should Be Equal + ... ${{{k: v.value for k, v in $DICT1.items()}}} + ... {"var": "Secret value", "env": "%{TEMPDIR}", "join": "=Secret value="} + ... type=dict + Should Be Equal + ... ${{{k: v.value for k, v in $DICT2.items()}}} + ... {2: "Secret value", "var": "Secret value", "env": "%{TEMPDIR}", "join": "=Secret value="} + ... type=dict + Should Be Equal + ... ${{{k.value: v.value for k, v in $DICT3.items()}}} + ... {"%{TEMPDIR}": "Secret value", "=%{TEMPDIR}=": "=Secret value="} + ... type=dict + +Variable section: Dict fail + Variable Should Not Exist ${DICT4} + Variable Should Not Exist ${DICT5} + +VAR: Based on existing variable + [Documentation] FAIL + ... Setting variable '${bad: secret}' failed: \ + ... Value '\${666}' must have type 'Secret', got integer. + VAR ${x: Secret} ${SECRET} + Should Be Equal ${x.value} Secret value + VAR ${x: Secret | int} ${SECRET} + Should Be Equal ${x.value} Secret value + VAR ${x: Secret | int} ${42} + Should Be Equal ${x} ${42} + VAR ${bad: secret} ${666} + +VAR: Based on environment variable + [Documentation] FAIL + ... Setting variable '\${nonex: Secret}' failed: \ + ... Environment variable '\%{NONEX}' not found. Set Environment Variable SECRET VALUE1 VAR ${secret: secret} %{SECRET} Should Be Equal ${secret.value} VALUE1 - VAR ${x} SECRET Set Environment Variable SECRET VALUE2 - VAR ${secret: secret} %{${x}} + VAR ${secret: secret} %{${{'SECRET'}}} Should Be Equal ${secret.value} VALUE2 - VAR ${secret: secret} %{INLINE_SECRET=inline_secret} - Should Be Equal ${secret.value} inline_secret + VAR ${secret: secret} %{NONEX=default} + Should Be Equal ${secret.value} default + VAR ${not_secret: Secret | str} %{TEMPDIR} + Should Be Equal ${not_secret} %{TEMPDIR} + VAR ${nonex: Secret} %{NONEX} -VAR: Join secret +VAR: Joined [Documentation] FAIL ... Setting variable '\${zz: secret}' failed: \ ... Value '111\${y}222' must have type 'Secret', got string. - ${secret1} Library Get Secret 111 - ${secret2} Library Get Secret 222 + ${secret1} = Library Get Secret 111 + ${secret2} = Library Get Secret 222 VAR ${x: secret} abc${secret1} Should Be Equal ${x.value} abc111 VAR ${y: int} 42 @@ -100,78 +133,68 @@ VAR: Broken variable ... Setting variable '\${x: Secret}' failed: Variable '${borken' was not closed properly. VAR ${x: Secret} ${borken -Create: List +VAR: List [Documentation] FAIL - ... Setting variable '\@{x: secret}' failed: \ - ... Value 'this' must have type 'Secret', got string. - VAR @{x: secret} ${SECRET} ${SECRET} - Should Be Equal ${x[0].value} This is a secret used in tests - Should Be Equal ${x[1].value} This is a secret used in tests - VAR @{x: int|secret} 22 ${SECRET} 44 - Should Be Equal ${x[0]} 22 type=int - Should Be Equal ${x[1].value} This is a secret used in tests - Should Be Equal ${x[2]} 44 type=int - VAR @{x: secret} ${SECRET} this fails - -Create: List by extending - VAR @{x: secret} ${SECRET} ${SECRET} - VAR @{x} @{x} @{x} - Length Should Be ${x} 4 - Should Be Equal ${x[0].value} This is a secret used in tests - Should Be Equal ${x[1].value} This is a secret used in tests - Should Be Equal ${x[2].value} This is a secret used in tests - Should Be Equal ${x[3].value} This is a secret used in tests - -Create: List of dictionaries - VAR &{dict1: secret} key1=${SECRET} key2=${SECRET} - VAR &{dict2: secret} key3=${SECRET} - VAR @{list} ${dict1} ${dict2} - Length Should Be ${list} 2 - FOR ${d} IN @{list} - Dictionaries Should Be Equal ${d} ${d} - END - -Create: Dictionary + ... Setting variable '@{x: Secret | int}' failed: \ + ... Value '[Secret(value=), 'this', 'fails']' (list) cannot be converted to list[Secret | int]: \ + ... Item '1' got value 'this' that cannot be converted to Secret or integer. + VAR @{x: secret} ${SECRET} %{TEMPDIR} \${escaped} with ${SECRET} + Should Be Equal + ... ${{[item.value for item in $x]}} + ... ["Secret value", "%{TEMPDIR}", "\${escaped} with Secret value"] + ... type=list + VAR @{y: Secret} @{x} @{EMPTY} ${SECRET} + Should Be Equal + ... ${{[item.value for item in $y]}} + ... ["Secret value", "%{TEMPDIR}", "\${escaped} with Secret value", "Secret value"] + ... type=list + VAR @{z: int|secret} 22 ${SECRET} 44 + Should Be Equal ${z} ${{[22, $SECRET, 44]}} + VAR @{x: Secret | int} ${SECRET} this fails + +Create: Dict [Documentation] FAIL ... Setting variable '\&{x: secret}' failed: \ ... Value 'fails' must have type 'Secret', got string. - VAR &{x: secret} key=${SECRET} - Should Be Equal ${x.key.value} This is a secret used in tests - VAR &{x: int=secret} 42=${SECRET} - Should Be Equal ${x[42].value} This is a secret used in tests + VAR &{x: Secret} var=${SECRET} end=%{TEMPDIR} join==${SECRET}= + Should Be Equal + ... ${{{k: v.value for k, v in $DICT1.items()}}} + ... {"var": "Secret value", "env": "%{TEMPDIR}", "join": "=Secret value="} + ... type=dict + VAR &{x: Secret=int} ${SECRET}=42 + Should Be Equal ${x} ${{{$SECRET: 42}}} VAR &{x: secret} this=fails Return value: Library keyword [Documentation] FAIL ... ValueError: Return value must have type 'Secret', got string. - ${x} Library Get Secret + ${x} = Library Get Secret Should Be Equal ${x.value} This is a secret - ${x: Secret} Library Get Secret value of secret here + ${x: Secret} = Library Get Secret value of secret here Should Be Equal ${x.value} value of secret here - ${x: secret} Library Not Secret + ${x: secret} = Library Not Secret Return value: User keyword [Documentation] FAIL ... ValueError: Return value must have type 'Secret', got string. - ${x} User Keyword: Return secret + ${x} = User Keyword: Return secret Should Be Equal ${x.value} This is a secret - ${x: Secret} User Keyword: Return secret + ${x: Secret} = User Keyword: Return secret Should Be Equal ${x.value} This is a secret - ${x: secret} User Keyword: Return string + ${x: secret} = User Keyword: Return string User keyword: Receive not secret [Documentation] FAIL ... ValueError: Argument 'secret' must have type 'Secret', got string. - User Keyword: Receive secret xxx ${None} + User Keyword: Receive secret xxx User keyword: Receive not secret var [Documentation] FAIL - ... ValueError: Argument 'secret' must have type 'Secret', got string. - VAR ${x} y - User Keyword: Receive secret ${x} ${None} + ... ValueError: Argument 'secret' must have type 'Secret', got integer. + User Keyword: Receive secret ${666} Library keyword - User Keyword: Receive secret ${SECRET} This is a secret used in tests + User Keyword: Receive secret ${SECRET} Secret value Library keyword: not secret 1 [Documentation] FAIL @@ -189,20 +212,19 @@ Library keyword: TypedDict ... '{'username': 'login@email.com', 'password': 'This fails'}' (DotDict) that cannot be converted to Credential: \ ... Item 'password' must have type 'Secret', got string. VAR &{credentials} username=login@email.com password=${SECRET} - ${data} Library Receive Credential ${credentials} - Should Be Equal ${data} Username: login@email.com, Password: This is a secret used in tests + ${data} = Library Receive Credential ${credentials} + Should Be Equal ${data} Username: login@email.com, Password: Secret value VAR &{credentials} username=login@email.com password=This fails Library Receive Credential ${credentials} Library keyword: List of secrets VAR @{secrets: secret} ${SECRET} ${SECRET} - ${data} Library List Of Secrets ${secrets} - Should Be Equal ${data} This is a secret used in tests, This is a secret used in tests - + ${data} = Library List Of Secrets ${secrets} + Should Be Equal ${data} Secret value, Secret value *** Keywords *** User Keyword: Receive secret - [Arguments] ${secret: secret} ${expected: str} + [Arguments] ${secret: secret} ${expected: str}=not set Should Be Equal ${secret.value} ${expected} User Keyword: Return secret diff --git a/src/robot/utils/_secret.py b/src/robot/utils/_secret.py index 63968c83f47..1a59c5bb728 100644 --- a/src/robot/utils/_secret.py +++ b/src/robot/utils/_secret.py @@ -35,3 +35,6 @@ def __init__(self, value: str): def __str__(self) -> str: return f"{type(self).__name__}(value=)" + + def __repr__(self): + return str(self) diff --git a/src/robot/variables/tablesetter.py b/src/robot/variables/tablesetter.py index 1dda347fc5b..caff50f7651 100644 --- a/src/robot/variables/tablesetter.py +++ b/src/robot/variables/tablesetter.py @@ -16,7 +16,7 @@ from typing import Any, Callable, Sequence, TYPE_CHECKING from robot.errors import DataError -from robot.utils import DotDict, safe_str, Secret, split_from_equals, type_name +from robot.utils import DotDict, safe_str, Secret, split_from_equals, type_name, unescape from .resolvable import Resolvable from .search import is_dict_variable, is_list_variable, search_variable @@ -121,7 +121,7 @@ def _handle_secrets(self, value, replace_scalar): secret = self._handle_embedded_secrets(match, replace_scalar) if not isinstance(secret, Secret): raise DataError( - f"Value '{value}' must have type 'Secret', got {type_name(value)}." + f"Value '{value}' must have type 'Secret', got {type_name(secret)}." ) return secret @@ -135,9 +135,9 @@ def _handle_embedded_secrets(self, match, replace_scalar): elif isinstance(secret, Secret): secret_seen = True secret = secret.value - parts.extend([match.before, secret]) + parts.extend([unescape(match.before), secret]) match = search_variable(match.after, identifiers="$%") - parts.append(match.string) + parts.append(unescape(match.string)) value = "".join(safe_str(p) for p in parts) return Secret(value) if secret_seen else value @@ -203,12 +203,15 @@ def _is_single_value(self, value, separator): class ListVariableResolver(VariableResolver): def _replace_variables(self, variables): - if self._is_secret_type(): - return [ - self._handle_secrets(value, variables.replace_scalar) - for value in self.value - ] - return variables.replace_list(self.value) + if not self._is_secret_type(): + return variables.replace_list(self.value) + secrets = [] + for value in self.value: + if is_list_variable(value): + secrets.extend(variables.replace_scalar(value)) + else: + secrets.append(self._handle_secrets(value, variables.replace_scalar)) + return secrets def _convert(self, value, type_): return super()._convert(value, f"list[{type_}]") From 5ca887ef2cbb4fc7f0d58feeed45d9e9dd40c4b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Tue, 9 Sep 2025 22:58:39 +0300 Subject: [PATCH 31/39] Secret: Simplify error reporting When creating variables, don't report error about value not being secret after replacing variables. There is no need for that, because the error is anyway reported in the next step when conversion is attempted. This simplifies code and gives consistent error messages. See issue #4537 and PR #5449. --- .../keywords/type_conversion/secret.robot | 30 ++++++++++++------- .../keywords/type_conversion/secret.robot | 27 ++++++++++++----- src/robot/variables/tablesetter.py | 21 +++++-------- 3 files changed, 46 insertions(+), 32 deletions(-) diff --git a/atest/robot/keywords/type_conversion/secret.robot b/atest/robot/keywords/type_conversion/secret.robot index 241f5837ee2..be4ea7bb0a2 100644 --- a/atest/robot/keywords/type_conversion/secret.robot +++ b/atest/robot/keywords/type_conversion/secret.robot @@ -22,15 +22,15 @@ Variable section: Scalar fail Error In File ... 6 keywords/type_conversion/secret.robot 11 ... Setting variable '\${LITERAL: Secret}' failed: - ... Value 'this fails' must have type 'Secret', got string. + ... Value must have type 'Secret', got string. Error In File ... 0 keywords/type_conversion/secret.robot 12 ... Setting variable '\${BAD: Secret}' failed: - ... Value '\${666}' must have type 'Secret', got integer. + ... Value must have type 'Secret', got integer. Error In File ... 3 keywords/type_conversion/secret.robot 16 ... Setting variable '\${JOIN4: Secret}' failed: - ... Value 'this fails \${2}!' must have type 'Secret', got string. + ... Value must have type 'Secret', got string. Variable section: List Check Test Case ${TESTNAME} @@ -40,12 +40,16 @@ Variable section: List fail Error In File ... 4 keywords/type_conversion/secret.robot 19 ... Setting variable '\@{LIST3: Secret}' failed: - ... Value 'this' must have type 'Secret', got string. + ... Value '['this', Secret(value=), 'fails']' (list) + ... cannot be converted to list[Secret]: + ... Item '0' must have type 'Secret', got string. + ... pattern=False Error In File ... 5 keywords/type_conversion/secret.robot 20 ... Setting variable '\@{LIST4: Secret}' failed: - ... Value '[Secret(value=), Secret(value=), Secret(value=), 'this', 'fails', Secret(value=)]' (list) cannot be converted to list[Secret]: - ... Item '3' must have type 'Secret', got string. + ... Value '[Secret(value=), 'this', 'fails', Secret(value=)]' (list) + ... cannot be converted to list[Secret]: + ... Item '1' must have type 'Secret', got string. ... pattern=False Variable section: Dict @@ -56,11 +60,16 @@ Variable section: Dict fail Error In File ... 1 keywords/type_conversion/secret.robot 24 ... Setting variable '\&{DICT4: Secret}' failed: - ... Value 'fails' must have type 'Secret', got string. + ... Value '{'ok': Secret(value=), 'this': 'fails'}' (DotDict) + ... cannot be converted to dict[Any, Secret]: + ... Item 'this' must have type 'Secret', got string. + ... pattern=False Error In File ... 2 keywords/type_conversion/secret.robot 25 - ... Setting variable '\&{DICT5: Secret}' failed: - ... Value '{'ok': Secret(value=), 'var': Secret(value=), 'env': Secret(value=), 'join': Secret(value=), 'this': 'fails'}' (DotDict) cannot be converted to dict[Any, Secret]: + ... Setting variable '\&{DICT5: str=Secret}' failed: + ... Value '{'ok': Secret(value=), 'var': Secret(value=), + ... 'env': Secret(value=), 'join': Secret(value=), 'this': 'fails'}' (DotDict) + ... cannot be converted to dict[str, Secret]: ... Item 'this' must have type 'Secret', got string. ... pattern=False @@ -80,7 +89,8 @@ VAR: List Check Test Case ${TESTNAME} Create: Dict - Check Test Case ${TESTNAME} + Check Test Case ${TESTNAME} 1 + Check Test Case ${TESTNAME} 2 Return value: Library keyword Check Test Case ${TESTNAME} diff --git a/atest/testdata/keywords/type_conversion/secret.robot b/atest/testdata/keywords/type_conversion/secret.robot index 64856984b94..09a2590b7b9 100644 --- a/atest/testdata/keywords/type_conversion/secret.robot +++ b/atest/testdata/keywords/type_conversion/secret.robot @@ -17,12 +17,12 @@ ${JOIN4: Secret} this fails ${2}! @{LIST1: Secret} ${SECRET} %{TEMPDIR} =${SECRET}= @{LIST2: Secret} ${SECRET} @{LIST1} @{EMPTY} @{LIST3: Secret} this ${SECRET} fails -@{LIST4: Secret} @{LIST1} @{{["this", "fails"]}} ${SECRET} +@{LIST4: Secret} ${SECRET} @{{["this", "fails"]}} ${SECRET} &{DICT1: Secret} var=${SECRET} env=%{TEMPDIR} join==${SECRET}= &{DICT2: Secret} ${2}=${SECRET} &{DICT1} &{EMPTY} &{DICT3: Secret=Secret} %{TEMPDIR}=${SECRET} \=%{TEMPDIR}\===${SECRET}= &{DICT4: Secret} ok=${SECRET} this=fails -&{DICT5: Secret} ok=${SECRET} &{DICT1} &{{{"this": "fails"}}} +&{DICT5: str=Secret} ok=${SECRET} &{DICT1} &{{{"this": "fails"}}} *** Test Cases *** Command line @@ -79,7 +79,7 @@ Variable section: Dict fail VAR: Based on existing variable [Documentation] FAIL ... Setting variable '${bad: secret}' failed: \ - ... Value '\${666}' must have type 'Secret', got integer. + ... Value must have type 'Secret', got integer. VAR ${x: Secret} ${SECRET} Should Be Equal ${x.value} Secret value VAR ${x: Secret | int} ${SECRET} @@ -107,7 +107,7 @@ VAR: Based on environment variable VAR: Joined [Documentation] FAIL ... Setting variable '\${zz: secret}' failed: \ - ... Value '111\${y}222' must have type 'Secret', got string. + ... Value must have type 'Secret', got string. ${secret1} = Library Get Secret 111 ${secret2} = Library Get Secret 222 VAR ${x: secret} abc${secret1} @@ -136,7 +136,8 @@ VAR: Broken variable VAR: List [Documentation] FAIL ... Setting variable '@{x: Secret | int}' failed: \ - ... Value '[Secret(value=), 'this', 'fails']' (list) cannot be converted to list[Secret | int]: \ + ... Value '[Secret(value=), 'this', 'fails']' (list) \ + ... cannot be converted to list[Secret | int]: \ ... Item '1' got value 'this' that cannot be converted to Secret or integer. VAR @{x: secret} ${SECRET} %{TEMPDIR} \${escaped} with ${SECRET} Should Be Equal @@ -152,10 +153,11 @@ VAR: List Should Be Equal ${z} ${{[22, $SECRET, 44]}} VAR @{x: Secret | int} ${SECRET} this fails -Create: Dict +Create: Dict 1 [Documentation] FAIL ... Setting variable '\&{x: secret}' failed: \ - ... Value 'fails' must have type 'Secret', got string. + ... Value '{'this': 'fails'}' (DotDict) cannot be converted to dict[Any, secret]: \ + ... Item 'this' must have type 'Secret', got string. VAR &{x: Secret} var=${SECRET} end=%{TEMPDIR} join==${SECRET}= Should Be Equal ... ${{{k: v.value for k, v in $DICT1.items()}}} @@ -165,6 +167,14 @@ Create: Dict Should Be Equal ${x} ${{{$SECRET: 42}}} VAR &{x: secret} this=fails +Create: Dict 2 + [Documentation] FAIL + ... Setting variable '\&{x: Secret=int}' failed: \ + ... Value '{Secret(value=): '42', 'bad': '666'}' (DotDict) \ + ... cannot be converted to dict[Secret, int]: \ + ... Key must have type 'Secret', got string. + VAR &{x: Secret=int} ${SECRET}=42 bad=666 + Return value: Library keyword [Documentation] FAIL ... ValueError: Return value must have type 'Secret', got string. @@ -209,7 +219,8 @@ Library keyword: not secret 2 Library keyword: TypedDict [Documentation] FAIL ... ValueError: Argument 'credential' got value \ - ... '{'username': 'login@email.com', 'password': 'This fails'}' (DotDict) that cannot be converted to Credential: \ + ... '{'username': 'login@email.com', 'password': 'This fails'}' (DotDict) \ + ... that cannot be converted to Credential: \ ... Item 'password' must have type 'Secret', got string. VAR &{credentials} username=login@email.com password=${SECRET} ${data} = Library Receive Credential ${credentials} diff --git a/src/robot/variables/tablesetter.py b/src/robot/variables/tablesetter.py index caff50f7651..a548b2b3cee 100644 --- a/src/robot/variables/tablesetter.py +++ b/src/robot/variables/tablesetter.py @@ -114,28 +114,21 @@ def _replace_variables(self, variables) -> Any: def _handle_secrets(self, value, replace_scalar): match = search_variable(value, identifiers="$%") if match.is_variable(): - secret = replace_scalar(match.match) - if match.identifier == "%": - secret = Secret(secret) - else: - secret = self._handle_embedded_secrets(match, replace_scalar) - if not isinstance(secret, Secret): - raise DataError( - f"Value '{value}' must have type 'Secret', got {type_name(secret)}." - ) - return secret + value = replace_scalar(match.match) + return Secret(value) if match.identifier == "%" else value + return self._handle_embedded_secrets(match, replace_scalar) def _handle_embedded_secrets(self, match, replace_scalar): parts = [] secret_seen = False while match: - secret = replace_scalar(match.match) + value = replace_scalar(match.match) if match.identifier == "%": secret_seen = True - elif isinstance(secret, Secret): + elif isinstance(value, Secret): + value = value.value secret_seen = True - secret = secret.value - parts.extend([unescape(match.before), secret]) + parts.extend([unescape(match.before), value]) match = search_variable(match.after, identifiers="$%") parts.append(unescape(match.string)) value = "".join(safe_str(p) for p in parts) From 90c0d5301e534e888fa2bbe4279b7ab52bafc19f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Wed, 10 Sep 2025 00:21:20 +0300 Subject: [PATCH 32/39] Fixes and enhancements to Secret tests (#4537) Most importantly, try to fix tests on Windows. --- .../keywords/type_conversion/secret.robot | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/atest/testdata/keywords/type_conversion/secret.robot b/atest/testdata/keywords/type_conversion/secret.robot index 09a2590b7b9..155910bcdd8 100644 --- a/atest/testdata/keywords/type_conversion/secret.robot +++ b/atest/testdata/keywords/type_conversion/secret.robot @@ -47,11 +47,11 @@ Variable section: Scalar fail Variable section: List Should Be Equal ... ${{[item.value for item in $LIST1]}} - ... ["Secret value", "%{TEMPDIR}", "=Secret value="] + ... ["Secret value", r"%{TEMPDIR}", "=Secret value="] ... type=list Should Be Equal ... ${{[item.value for item in $LIST2]}} - ... ["Secret value", "Secret value", "%{TEMPDIR}", "=Secret value="] + ... ["Secret value", "Secret value", r"%{TEMPDIR}", "=Secret value="] ... type=list Variable section: List fail @@ -61,15 +61,15 @@ Variable section: List fail Variable section: Dict Should Be Equal ... ${{{k: v.value for k, v in $DICT1.items()}}} - ... {"var": "Secret value", "env": "%{TEMPDIR}", "join": "=Secret value="} + ... {"var": "Secret value", "env": r"%{TEMPDIR}", "join": "=Secret value="} ... type=dict Should Be Equal ... ${{{k: v.value for k, v in $DICT2.items()}}} - ... {2: "Secret value", "var": "Secret value", "env": "%{TEMPDIR}", "join": "=Secret value="} + ... {2: "Secret value", "var": "Secret value", "env": r"%{TEMPDIR}", "join": "=Secret value="} ... type=dict Should Be Equal ... ${{{k.value: v.value for k, v in $DICT3.items()}}} - ... {"%{TEMPDIR}": "Secret value", "=%{TEMPDIR}=": "=Secret value="} + ... {r"%{TEMPDIR}": "Secret value", r"=%{TEMPDIR}=": "=Secret value="} ... type=dict Variable section: Dict fail @@ -100,6 +100,8 @@ VAR: Based on environment variable Should Be Equal ${secret.value} VALUE2 VAR ${secret: secret} %{NONEX=default} Should Be Equal ${secret.value} default + VAR ${secret: secret} %{=not so secret} + Should Be Equal ${secret.value} not so secret VAR ${not_secret: Secret | str} %{TEMPDIR} Should Be Equal ${not_secret} %{TEMPDIR} VAR ${nonex: Secret} %{NONEX} @@ -142,12 +144,12 @@ VAR: List VAR @{x: secret} ${SECRET} %{TEMPDIR} \${escaped} with ${SECRET} Should Be Equal ... ${{[item.value for item in $x]}} - ... ["Secret value", "%{TEMPDIR}", "\${escaped} with Secret value"] + ... ["Secret value", r"%{TEMPDIR}", "\${escaped} with Secret value"] ... type=list VAR @{y: Secret} @{x} @{EMPTY} ${SECRET} Should Be Equal ... ${{[item.value for item in $y]}} - ... ["Secret value", "%{TEMPDIR}", "\${escaped} with Secret value", "Secret value"] + ... ["Secret value", r"%{TEMPDIR}", "\${escaped} with Secret value", "Secret value"] ... type=list VAR @{z: int|secret} 22 ${SECRET} 44 Should Be Equal ${z} ${{[22, $SECRET, 44]}} @@ -161,7 +163,7 @@ Create: Dict 1 VAR &{x: Secret} var=${SECRET} end=%{TEMPDIR} join==${SECRET}= Should Be Equal ... ${{{k: v.value for k, v in $DICT1.items()}}} - ... {"var": "Secret value", "env": "%{TEMPDIR}", "join": "=Secret value="} + ... {"var": "Secret value", "env": r"%{TEMPDIR}", "join": "=Secret value="} ... type=dict VAR &{x: Secret=int} ${SECRET}=42 Should Be Equal ${x} ${{{$SECRET: 42}}} From 3f91560c8b7f650f333bb2f45b0257ca1c1bcc6a Mon Sep 17 00:00:00 2001 From: silentw0lf <110233337+silentw0lf@users.noreply.github.com> Date: Wed, 10 Sep 2025 10:56:12 +0200 Subject: [PATCH 33/39] Add type hints to DateTime library (#5488) Part of issue #5373. --------- Co-authored-by: Clemens Otto --- .../datetime/convert_date_input_format.robot | 2 +- src/robot/libraries/DateTime.py | 148 +++++++++++------- src/robot/utils/robottime.py | 4 +- 3 files changed, 94 insertions(+), 60 deletions(-) diff --git a/atest/testdata/standard_libraries/datetime/convert_date_input_format.robot b/atest/testdata/standard_libraries/datetime/convert_date_input_format.robot index 67e0e017491..9d9e656bddf 100644 --- a/atest/testdata/standard_libraries/datetime/convert_date_input_format.robot +++ b/atest/testdata/standard_libraries/datetime/convert_date_input_format.robot @@ -63,7 +63,7 @@ Invalid input 2014-06-5 Invalid timestamp '2014-06-5'. 2014-06-05 * %Y-%m-%d %H:%M:%S.%f 2015-xxx * %Y-%f - ${NONE} Unsupported input 'None'. + ${NONE} Invalid timestamp 'None'. *** Keywords *** Date Conversion Should Succeed diff --git a/src/robot/libraries/DateTime.py b/src/robot/libraries/DateTime.py index 3a482d9086f..64ff2089343 100644 --- a/src/robot/libraries/DateTime.py +++ b/src/robot/libraries/DateTime.py @@ -307,6 +307,7 @@ import datetime import sys import time +from typing import Union from robot.utils import ( elapsed_time_to_string, secs_to_timestr, timestr_to_secs, type_name @@ -325,13 +326,18 @@ "subtract_time_from_time", ] +DateOutput = Union[datetime.datetime, float, str] +DateInput = Union[datetime.datetime, datetime.date, float, int, str] +TimeInput = Union[datetime.timedelta, float, int, str] +TimeOutput = Union[datetime.timedelta, float, str] + def get_current_date( - time_zone="local", - increment=0, - result_format="timestamp", - exclude_millis=False, -): + time_zone: str = "local", + increment: TimeInput = 0, + result_format: str = "timestamp", + exclude_millis: bool = False, +) -> DateOutput: """Returns current local or UTC time with an optional increment. Arguments: @@ -373,11 +379,11 @@ def get_current_date( def convert_date( - date, - result_format="timestamp", - exclude_millis=False, - date_format=None, -): + date: DateInput, + result_format: str = "timestamp", + exclude_millis: bool = False, + date_format: "str | None" = None, +) -> DateOutput: """Converts between supported `date formats`. Arguments: @@ -398,7 +404,11 @@ def convert_date( return Date(date, date_format).convert(result_format, millis=not exclude_millis) -def convert_time(time, result_format="number", exclude_millis=False): +def convert_time( + time: TimeInput, + result_format: str = "number", + exclude_millis: bool = False, +) -> TimeOutput: """Converts between supported `time formats`. Arguments: @@ -419,13 +429,13 @@ def convert_time(time, result_format="number", exclude_millis=False): def subtract_date_from_date( - date1, - date2, - result_format="number", - exclude_millis=False, - date1_format=None, - date2_format=None, -): + date1: DateInput, + date2: DateInput, + result_format: str = "number", + exclude_millis: bool = False, + date1_format: "str | None" = None, + date2_format: "str | None" = None, +) -> TimeOutput: """Subtracts date from another date and returns time between. Arguments: @@ -450,12 +460,12 @@ def subtract_date_from_date( def add_time_to_date( - date, - time, - result_format="timestamp", - exclude_millis=False, - date_format=None, -): + date: DateInput, + time: TimeInput, + result_format: str = "timestamp", + exclude_millis: bool = False, + date_format: "str | None" = None, +) -> DateOutput: """Adds time to date and returns the resulting date. Arguments: @@ -479,12 +489,12 @@ def add_time_to_date( def subtract_time_from_date( - date, - time, - result_format="timestamp", - exclude_millis=False, - date_format=None, -): + date: DateInput, + time: TimeInput, + result_format: str = "timestamp", + exclude_millis: bool = False, + date_format: "str | None" = None, +) -> DateOutput: """Subtracts time from date and returns the resulting date. Arguments: @@ -507,7 +517,12 @@ def subtract_time_from_date( return date.convert(result_format, millis=not exclude_millis) -def add_time_to_time(time1, time2, result_format="number", exclude_millis=False): +def add_time_to_time( + time1: TimeInput, + time2: TimeInput, + result_format: str = "number", + exclude_millis: bool = False, +) -> TimeOutput: """Adds time to another time and returns the resulting time. Arguments: @@ -527,7 +542,12 @@ def add_time_to_time(time1, time2, result_format="number", exclude_millis=False) return time.convert(result_format, millis=not exclude_millis) -def subtract_time_from_time(time1, time2, result_format="number", exclude_millis=False): +def subtract_time_from_time( + time1: TimeInput, + time2: TimeInput, + result_format: str = "number", + exclude_millis: bool = False, +) -> TimeOutput: """Subtracts time from another time and returns the resulting time. Arguments: @@ -550,15 +570,23 @@ def subtract_time_from_time(time1, time2, result_format="number", exclude_millis class Date: - def __init__(self, date, input_format=None): + def __init__( + self, + date: DateInput, + input_format: "str | None" = None, + ): self.datetime = self._convert_to_datetime(date, input_format) @property - def seconds(self): + def seconds(self) -> float: # Mainly for backwards compatibility with RF 2.9.1 and earlier. return self._convert_to_epoch(self.datetime) - def _convert_to_datetime(self, date, input_format): + def _convert_to_datetime( + self, + date: DateInput, + input_format: "str | None", + ) -> datetime.datetime: if isinstance(date, datetime.datetime): return date if isinstance(date, datetime.date): @@ -569,16 +597,20 @@ def _convert_to_datetime(self, date, input_format): return self._string_to_datetime(date, input_format) raise ValueError(f"Unsupported input '{date}'.") - def _epoch_seconds_to_datetime(self, secs): + def _epoch_seconds_to_datetime(self, secs: float) -> datetime.datetime: return datetime.datetime.fromtimestamp(secs) - def _string_to_datetime(self, ts, input_format): + def _string_to_datetime( + self, + timestamp: str, + input_format: "str | None", + ) -> datetime.datetime: if not input_format: - ts = self._normalize_timestamp(ts) + timestamp = self._normalize_timestamp(timestamp) input_format = "%Y-%m-%d %H:%M:%S.%f" - return datetime.datetime.strptime(ts, input_format) + return datetime.datetime.strptime(timestamp, input_format) - def _normalize_timestamp(self, timestamp): + def _normalize_timestamp(self, timestamp: str) -> str: numbers = "".join(d for d in timestamp if d.isdigit()) if not (8 <= len(numbers) <= 20): raise ValueError(f"Invalid timestamp '{timestamp}'.") @@ -586,7 +618,7 @@ def _normalize_timestamp(self, timestamp): t = numbers[8:].ljust(12, "0") return f"{d[:4]}-{d[4:6]}-{d[6:8]} {t[:2]}:{t[2:4]}:{t[4:6]}.{t[6:]}" - def convert(self, format, millis=True): + def convert(self, format: str, millis: bool = True) -> DateOutput: dt = self.datetime if not millis: secs = 1 if dt.microsecond >= 5e5 else 0 @@ -602,10 +634,10 @@ def convert(self, format, millis=True): return self._convert_to_epoch(dt) raise ValueError(f"Unknown format '{format}'.") - def _convert_to_custom_timestamp(self, dt, format): + def _convert_to_custom_timestamp(self, dt: datetime.datetime, format: str) -> str: return dt.strftime(format) - def _convert_to_timestamp(self, dt, millis=True): + def _convert_to_timestamp(self, dt: datetime.datetime, millis: bool = True) -> str: if not millis: return dt.strftime("%Y-%m-%d %H:%M:%S") ms = round(dt.microsecond / 1000) @@ -614,19 +646,19 @@ def _convert_to_timestamp(self, dt, millis=True): ms = 0 return dt.strftime("%Y-%m-%d %H:%M:%S") + f".{ms:03d}" - def _convert_to_epoch(self, dt): + def _convert_to_epoch(self, dt: datetime.datetime) -> float: try: return dt.timestamp() except OSError: # https://github.com/python/cpython/issues/81708 return time.mktime(dt.timetuple()) + dt.microsecond / 1e6 - def __add__(self, other): + def __add__(self, other: "Time") -> "Date": if isinstance(other, Time): return Date(self.datetime + other.timedelta) raise TypeError(f"Can only add Time to Date, got {type_name(other)}.") - def __sub__(self, other): + def __sub__(self, other: "Date | Time") -> "Date | Time": if isinstance(other, Date): return Time(self.datetime - other.datetime) if isinstance(other, Time): @@ -638,19 +670,19 @@ def __sub__(self, other): class Time: - def __init__(self, time): - self.seconds = float(self._convert_time_to_seconds(time)) + def __init__(self, time: TimeInput): + self.seconds = self._convert_time_to_seconds(time) - def _convert_time_to_seconds(self, time): + def _convert_time_to_seconds(self, time: TimeInput) -> float: if isinstance(time, datetime.timedelta): return time.total_seconds() return timestr_to_secs(time, round_to=None) @property - def timedelta(self): + def timedelta(self) -> datetime.timedelta: return datetime.timedelta(seconds=self.seconds) - def convert(self, format, millis=True): + def convert(self, format: str, millis: bool = True) -> TimeOutput: try: result_converter = getattr(self, f"_convert_to_{format.lower()}") except AttributeError: @@ -658,27 +690,27 @@ def convert(self, format, millis=True): seconds = self.seconds if millis else float(round(self.seconds)) return result_converter(seconds, millis) - def _convert_to_number(self, seconds, millis=True): + def _convert_to_number(self, seconds: float, _) -> float: return seconds - def _convert_to_verbose(self, seconds, millis=True): + def _convert_to_verbose(self, seconds: float, _) -> str: return secs_to_timestr(seconds) - def _convert_to_compact(self, seconds, millis=True): + def _convert_to_compact(self, seconds: float, _) -> str: return secs_to_timestr(seconds, compact=True) - def _convert_to_timer(self, seconds, millis=True): + def _convert_to_timer(self, seconds: float, millis: bool = True) -> str: return elapsed_time_to_string(seconds, include_millis=millis, seconds=True) - def _convert_to_timedelta(self, seconds, millis=True): + def _convert_to_timedelta(self, seconds: float, _) -> datetime.timedelta: return datetime.timedelta(seconds=seconds) - def __add__(self, other): + def __add__(self, other: "Time") -> "Time": if isinstance(other, Time): return Time(self.seconds + other.seconds) raise TypeError(f"Can only add Time to Time, got {type_name(other)}.") - def __sub__(self, other): + def __sub__(self, other: "Time") -> "Time": if isinstance(other, Time): return Time(self.seconds - other.seconds) raise TypeError(f"Can only subtract Time from Time, got {type_name(other)}.") diff --git a/src/robot/utils/robottime.py b/src/robot/utils/robottime.py index 530a4ae7b46..ffdff3bbd61 100644 --- a/src/robot/utils/robottime.py +++ b/src/robot/utils/robottime.py @@ -38,7 +38,9 @@ def _float_secs_to_secs_and_millis(secs): return (isecs, millis) if millis < 1000 else (isecs + 1, 0) -def timestr_to_secs(timestr, round_to=3): +def timestr_to_secs( + timestr: "timedelta | int | float | str", round_to: int = 3 +) -> float: """Parses time strings like '1h 10s', '01:00:10' and '42' and returns seconds. Time can also be given as an integer or float or, starting from RF 6.0.1, From 2b891177730b3e9deee2e3e4cfbf0b093a904eef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Wed, 10 Sep 2025 11:57:09 +0300 Subject: [PATCH 34/39] Remove unused import --- src/robot/variables/tablesetter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/robot/variables/tablesetter.py b/src/robot/variables/tablesetter.py index a548b2b3cee..0824fa3dd28 100644 --- a/src/robot/variables/tablesetter.py +++ b/src/robot/variables/tablesetter.py @@ -16,7 +16,7 @@ from typing import Any, Callable, Sequence, TYPE_CHECKING from robot.errors import DataError -from robot.utils import DotDict, safe_str, Secret, split_from_equals, type_name, unescape +from robot.utils import DotDict, safe_str, Secret, split_from_equals, unescape from .resolvable import Resolvable from .search import is_dict_variable, is_list_variable, search_variable From a09415f5ae3b7466a705f6da511949d7e330bea4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Fri, 12 Sep 2025 11:40:27 +0300 Subject: [PATCH 35/39] DateTime: Restrict accepted argument values with Literal. Values were already validated by keywords themselves so there are no real functional changes. The main benefit is getting the accepted values shown automatically in documentation and in some cases also error messages got better. In the future IDEs may also be able to auto-complete the accepted values. Existing argument validation wasn't removed from keywords to avoid problems in programmatic usage. This is related to adding typing to standard libraries (#5373). --- .../datetime/convert_time_result_format.robot | 4 ++- .../datetime/get_current_date.robot | 3 +- src/robot/libraries/DateTime.py | 31 +++++++++---------- src/robot/utils/robottime.py | 3 +- 4 files changed, 21 insertions(+), 20 deletions(-) diff --git a/atest/testdata/standard_libraries/datetime/convert_time_result_format.robot b/atest/testdata/standard_libraries/datetime/convert_time_result_format.robot index b2f4904e235..574dc85e5e6 100644 --- a/atest/testdata/standard_libraries/datetime/convert_time_result_format.robot +++ b/atest/testdata/standard_libraries/datetime/convert_time_result_format.robot @@ -55,7 +55,9 @@ Number is float regardless are millis included or not ${1000.123} 1000.0 ${1} ${1000} 1000.0 no millis -Invalid format [Documentation] FAIL ValueError: Unknown format 'invalid'. +Invalid format [Documentation] FAIL + ... ValueError: Argument 'result_format' got value 'invalid' that cannot be \ + ... converted to 'number', 'verbose', 'compact', 'timer' or 'timedelta'. 10s invalid 0 *** Keywords *** diff --git a/atest/testdata/standard_libraries/datetime/get_current_date.robot b/atest/testdata/standard_libraries/datetime/get_current_date.robot index 160faf79a47..d58197054be 100644 --- a/atest/testdata/standard_libraries/datetime/get_current_date.robot +++ b/atest/testdata/standard_libraries/datetime/get_current_date.robot @@ -24,7 +24,8 @@ UTC Time Compare Datatimes ${utc2} ${local} difference=-${TIMEZONE} Invalid time zone - [Documentation] FAIL ValueError: Unsupported timezone 'invalid'. + [Documentation] FAIL + ... ValueError: Argument 'time_zone' got value 'invalid' that cannot be converted to 'local' or 'UTC'. Get Current Date invalid Increment diff --git a/src/robot/libraries/DateTime.py b/src/robot/libraries/DateTime.py index 64ff2089343..dd884937766 100644 --- a/src/robot/libraries/DateTime.py +++ b/src/robot/libraries/DateTime.py @@ -307,7 +307,7 @@ import datetime import sys import time -from typing import Union +from typing import Literal, Union from robot.utils import ( elapsed_time_to_string, secs_to_timestr, timestr_to_secs, type_name @@ -326,16 +326,18 @@ "subtract_time_from_time", ] -DateOutput = Union[datetime.datetime, float, str] DateInput = Union[datetime.datetime, datetime.date, float, int, str] +DateOutput = Union[datetime.datetime, float, str] +DateFormat = Union[Literal["timestamp", "datetime", "epoch"], str] TimeInput = Union[datetime.timedelta, float, int, str] TimeOutput = Union[datetime.timedelta, float, str] +TimeFormat = Literal["number", "verbose", "compact", "timer", "timedelta"] def get_current_date( - time_zone: str = "local", + time_zone: Literal["local", "UTC"] = "local", increment: TimeInput = 0, - result_format: str = "timestamp", + result_format: DateFormat = "timestamp", exclude_millis: bool = False, ) -> DateOutput: """Returns current local or UTC time with an optional increment. @@ -380,7 +382,7 @@ def get_current_date( def convert_date( date: DateInput, - result_format: str = "timestamp", + result_format: DateFormat = "timestamp", exclude_millis: bool = False, date_format: "str | None" = None, ) -> DateOutput: @@ -406,7 +408,7 @@ def convert_date( def convert_time( time: TimeInput, - result_format: str = "number", + result_format: TimeFormat = "number", exclude_millis: bool = False, ) -> TimeOutput: """Converts between supported `time formats`. @@ -431,7 +433,7 @@ def convert_time( def subtract_date_from_date( date1: DateInput, date2: DateInput, - result_format: str = "number", + result_format: TimeFormat = "number", exclude_millis: bool = False, date1_format: "str | None" = None, date2_format: "str | None" = None, @@ -462,7 +464,7 @@ def subtract_date_from_date( def add_time_to_date( date: DateInput, time: TimeInput, - result_format: str = "timestamp", + result_format: DateFormat = "timestamp", exclude_millis: bool = False, date_format: "str | None" = None, ) -> DateOutput: @@ -491,7 +493,7 @@ def add_time_to_date( def subtract_time_from_date( date: DateInput, time: TimeInput, - result_format: str = "timestamp", + result_format: DateFormat = "timestamp", exclude_millis: bool = False, date_format: "str | None" = None, ) -> DateOutput: @@ -520,7 +522,7 @@ def subtract_time_from_date( def add_time_to_time( time1: TimeInput, time2: TimeInput, - result_format: str = "number", + result_format: TimeFormat = "number", exclude_millis: bool = False, ) -> TimeOutput: """Adds time to another time and returns the resulting time. @@ -545,7 +547,7 @@ def add_time_to_time( def subtract_time_from_time( time1: TimeInput, time2: TimeInput, - result_format: str = "number", + result_format: TimeFormat = "number", exclude_millis: bool = False, ) -> TimeOutput: """Subtracts time from another time and returns the resulting time. @@ -671,12 +673,7 @@ def __sub__(self, other: "Date | Time") -> "Date | Time": class Time: def __init__(self, time: TimeInput): - self.seconds = self._convert_time_to_seconds(time) - - def _convert_time_to_seconds(self, time: TimeInput) -> float: - if isinstance(time, datetime.timedelta): - return time.total_seconds() - return timestr_to_secs(time, round_to=None) + self.seconds = timestr_to_secs(time, round_to=None) @property def timedelta(self) -> datetime.timedelta: diff --git a/src/robot/utils/robottime.py b/src/robot/utils/robottime.py index ffdff3bbd61..e12657f28a4 100644 --- a/src/robot/utils/robottime.py +++ b/src/robot/utils/robottime.py @@ -39,7 +39,8 @@ def _float_secs_to_secs_and_millis(secs): def timestr_to_secs( - timestr: "timedelta | int | float | str", round_to: int = 3 + timestr: "timedelta | int | float | str", + round_to: "int | None" = 3, ) -> float: """Parses time strings like '1h 10s', '01:00:10' and '42' and returns seconds. From 89edfe650616cfca929a9835a88192675a94013a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Fri, 12 Sep 2025 11:59:39 +0300 Subject: [PATCH 36/39] Make `robot.api.types` file, not directory, based module --- src/robot/api/{types/__init__.py => types.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/robot/api/{types/__init__.py => types.py} (100%) diff --git a/src/robot/api/types/__init__.py b/src/robot/api/types.py similarity index 100% rename from src/robot/api/types/__init__.py rename to src/robot/api/types.py From 50b6c479d20b826e2d5c86f287b36c0a0f73107e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Fri, 12 Sep 2025 15:48:02 +0300 Subject: [PATCH 37/39] `robot.utils._secret` -> `robot.utils.secret` We'd like the `Secret` type (#4537) to be implemented under `robot.api`, but that causes very-hard-to-resolve cyclic import issues. After some failed attempts, we decided to keep it under `robot.utils`. At some point the idea was to "hide" the implementation under `robot.utils` by naming the module `_secret`, but we haven't used such convention with other modules so better to go with normal `secret` name. The docs will be updated later to explain that the class should be imported under `robot.api.types`. --- src/robot/utils/__init__.py | 2 +- src/robot/utils/{_secret.py => secret.py} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/robot/utils/{_secret.py => secret.py} (100%) diff --git a/src/robot/utils/__init__.py b/src/robot/utils/__init__.py index 33420ea1699..2dc27bbf60d 100644 --- a/src/robot/utils/__init__.py +++ b/src/robot/utils/__init__.py @@ -35,7 +35,6 @@ import warnings -from ._secret import Secret as Secret from .application import Application as Application from .argumentparser import ( ArgumentParser as ArgumentParser, @@ -145,6 +144,7 @@ type_repr as type_repr, typeddict_types as typeddict_types, ) +from .secret import Secret as Secret from .setter import setter as setter, SetterAwareType as SetterAwareType from .sortable import Sortable as Sortable from .text import ( diff --git a/src/robot/utils/_secret.py b/src/robot/utils/secret.py similarity index 100% rename from src/robot/utils/_secret.py rename to src/robot/utils/secret.py From 92bc1db4febd7d1564228debd04248cc7d20b3ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Fri, 12 Sep 2025 16:13:05 +0300 Subject: [PATCH 38/39] Avoid docutils configuration at module import time Fixes #5498. --- src/robot/utils/restreader.py | 41 ++++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/src/robot/utils/restreader.py b/src/robot/utils/restreader.py index 805a6a03190..f8e31ba6ebd 100644 --- a/src/robot/utils/restreader.py +++ b/src/robot/utils/restreader.py @@ -14,6 +14,7 @@ # limitations under the License. import functools +from contextlib import contextmanager from robot.errors import DataError @@ -31,6 +32,7 @@ class RobotDataStorage: + def __init__(self, doctree): if not hasattr(doctree, "_robot_data"): doctree._robot_data = [] @@ -55,18 +57,10 @@ def run(self): return [] -register_directive("code", RobotCodeBlock) -register_directive("code-block", RobotCodeBlock) -register_directive("sourcecode", RobotCodeBlock) - - -relevant_directives = (RobotCodeBlock, Include) - - @functools.wraps(directives.directive) def directive(*args, **kwargs): directive_class, messages = directive.__wrapped__(*args, **kwargs) - if directive_class not in relevant_directives: + if directive_class not in (RobotCodeBlock, Include): # Skipping unknown or non-relevant directive entirely directive_class = lambda *args, **kwargs: [] return directive_class, messages @@ -80,15 +74,28 @@ def role(*args, **kwargs): return role_function -directives.directive = directive -roles.role = role +@contextmanager +def docutils_config(): + orig_directive, orig_role = directives.directive, roles.role + directives.directive, roles.role = directive, role + register_directive("code", RobotCodeBlock) + register_directive("code-block", RobotCodeBlock) + register_directive("sourcecode", RobotCodeBlock) + try: + yield + finally: + directives.directive, roles.role = orig_directive, orig_role + register_directive("code", CodeBlock) + register_directive("code-block", CodeBlock) + register_directive("sourcecode", CodeBlock) def read_rest_data(rstfile): - doctree = publish_doctree( - rstfile.read(), - source_path=rstfile.name, - settings_overrides={"input_encoding": "UTF-8", "report_level": 4}, - ) - store = RobotDataStorage(doctree) + with docutils_config(): + doc = publish_doctree( + rstfile.read(), + source_path=rstfile.name, + settings_overrides={"input_encoding": "UTF-8", "report_level": 4}, + ) + store = RobotDataStorage(doc) return store.get_data() From 7c5c2c232e4fd1b0e03dd17075d33d056c40303f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pekka=20Kl=C3=A4rck?= Date: Fri, 12 Sep 2025 16:14:51 +0300 Subject: [PATCH 39/39] Add module needed in API doc generation --- requirements-dev.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements-dev.txt b/requirements-dev.txt index 383caff2574..9eb2fbe4ac9 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -8,6 +8,7 @@ wheel docutils pygments >= 2.8 sphinx +sphinx-rtd-theme pydantic < 2 telnetlib-313-and-up; python_version >= "3.13" black >= 24