diff --git a/.github/workflows/acceptance_tests_cpython.yml b/.github/workflows/acceptance_tests_cpython.yml index 7e4a25739a0..1cc661642b0 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-dev', 'pypy-3.10' ] include: - os: ubuntu-latest set_display: export DISPLAY=:99; Xvfb :99 -screen 0 1024x768x24 -ac -noreset & sleep 3 @@ -33,10 +33,10 @@ 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 + 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' @@ -89,39 +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 }} - 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:${{ github.head_ref || github.ref_name }}", - result_dir - ] - - # Run rflogs upload - subprocess.check_call(cmd) - else: - print("No directory containing log.html found") - exit(1) diff --git a/.github/workflows/acceptance_tests_cpython_pr.yml b/.github/workflows/acceptance_tests_cpython_pr.yml index 1b49dc448fe..2b7e146fac8 100644 --- a/.github/workflows/acceptance_tests_cpython_pr.yml +++ b/.github/workflows/acceptance_tests_cpython_pr.yml @@ -26,10 +26,10 @@ 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 + 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' @@ -76,39 +76,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 }} - 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:${{ github.head_ref || github.ref_name }}", - result_dir - ] - - # Run rflogs upload - subprocess.check_call(cmd) - else: - print("No directory containing log.html found") - exit(1) diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index c52d155900d..6ce36165754 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-dev', 'pypy-3.8' ] exclude: - os: windows-latest python-version: 'pypy-3.8' @@ -29,10 +29,10 @@ 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 + 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 91eb380d330..a065ad8c0a3 100644 --- a/.github/workflows/unit_tests_pr.yml +++ b/.github/workflows/unit_tests_pr.yml @@ -15,16 +15,16 @@ 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 }} 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 + uses: actions/setup-python@v6.0.0 with: python-version: ${{ matrix.python-version }} architecture: 'x64' diff --git a/.github/workflows/web_tests.yml b/.github/workflows/web_tests.yml index 8e7cc6f03c9..b6ede34836a 100644 --- a/.github/workflows/web_tests.yml +++ b/.github/workflows/web_tests.yml @@ -19,10 +19,10 @@ 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 + uses: actions/setup-node@v5 with: node-version: "16" - name: Run tests 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/atest/robot/keywords/type_conversion/secret.robot b/atest/robot/keywords/type_conversion/secret.robot new file mode 100644 index 00000000000..be4ea7bb0a2 --- /dev/null +++ b/atest/robot/keywords/type_conversion/secret.robot @@ -0,0 +1,118 @@ +*** 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: Based on existing variable + Check Test Case ${TESTNAME} + +Variable section: Based on environment variable + Check Test Case ${TESTNAME} + +Variable section: Joined + Check Test Case ${TESTNAME} + +Variable section: Scalar fail + Check Test Case ${TESTNAME} + Error In File + ... 6 keywords/type_conversion/secret.robot 11 + ... Setting variable '\${LITERAL: Secret}' failed: + ... Value must have type 'Secret', got string. + Error In File + ... 0 keywords/type_conversion/secret.robot 12 + ... Setting variable '\${BAD: Secret}' failed: + ... Value must have type 'Secret', got integer. + Error In File + ... 3 keywords/type_conversion/secret.robot 16 + ... Setting variable '\${JOIN4: Secret}' failed: + ... Value must have type 'Secret', got string. + +Variable section: List + Check Test Case ${TESTNAME} + +Variable section: List fail + Check Test Case ${TESTNAME} + Error In File + ... 4 keywords/type_conversion/secret.robot 19 + ... Setting variable '\@{LIST3: Secret}' failed: + ... 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=), 'this', 'fails', Secret(value=)]' (list) + ... cannot be converted to list[Secret]: + ... Item '1' must have type 'Secret', got string. + ... pattern=False + +Variable section: Dict + Check Test Case ${TESTNAME} + +Variable section: Dict fail + Check Test Case ${TESTNAME} + Error In File + ... 1 keywords/type_conversion/secret.robot 24 + ... Setting variable '\&{DICT4: Secret}' failed: + ... 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: 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 + +VAR: Based on existing variable + Check Test Case ${TESTNAME} + +VAR: Based on environment variable + Check Test Case ${TESTNAME} + +VAR: Joined + Check Test Case ${TESTNAME} + +VAR: Broken variable + Check Test Case ${TESTNAME} + +VAR: List + Check Test Case ${TESTNAME} + +Create: Dict + Check Test Case ${TESTNAME} 1 + Check Test Case ${TESTNAME} 2 + +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/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/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/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/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/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/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/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 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/atest/robot/test_libraries/library_imports.robot b/atest/robot/test_libraries/library_imports.robot index fc03d601e16..203688aa5a0 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*' @@ -35,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 @@ -54,14 +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 -*** Keywords *** -Normalize Path And Ignore Drive - [Arguments] ${path} - ${path} = Normalize Path ${path} - Return From Keyword If os.sep == '/' ${path} - Return From Keyword ?${path[1:]} +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/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/keywords/type_conversion/secret.py b/atest/testdata/keywords/type_conversion/secret.py new file mode 100644 index 00000000000..17d2bf92f2f --- /dev/null +++ b/atest/testdata/keywords/type_conversion/secret.py @@ -0,0 +1,34 @@ +from typing import TypedDict + +from robot.api.types 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("Secret value")} diff --git a/atest/testdata/keywords/type_conversion/secret.robot b/atest/testdata/keywords/type_conversion/secret.robot new file mode 100644 index 00000000000..155910bcdd8 --- /dev/null +++ b/atest/testdata/keywords/type_conversion/secret.robot @@ -0,0 +1,248 @@ +*** Settings *** +Library Collections +Library OperatingSystem +Library secret.py +Variables secret.py + +*** Variables *** +${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} ${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: str=Secret} ok=${SECRET} &{DICT1} &{{{"this": "fails"}}} + +*** Test Cases *** +Command line + Should Be Equal ${CLI.value} From command line + +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 + ... ${{[item.value for item in $LIST1]}} + ... ["Secret value", r"%{TEMPDIR}", "=Secret value="] + ... type=list + Should Be Equal + ... ${{[item.value for item in $LIST2]}} + ... ["Secret value", "Secret value", r"%{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 + ... ${{{k: v.value for k, v in $DICT1.items()}}} + ... {"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": r"%{TEMPDIR}", "join": "=Secret value="} + ... type=dict + Should Be Equal + ... ${{{k.value: v.value for k, v in $DICT3.items()}}} + ... {r"%{TEMPDIR}": "Secret value", r"=%{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 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 + Set Environment Variable SECRET VALUE2 + VAR ${secret: secret} %{${{'SECRET'}}} + 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} + +VAR: Joined + [Documentation] FAIL + ... Setting variable '\${zz: secret}' failed: \ + ... Value 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 + +VAR: List + [Documentation] FAIL + ... 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", 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", r"%{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 1 + [Documentation] FAIL + ... Setting variable '\&{x: secret}' failed: \ + ... 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()}}} + ... {"var": "Secret value", "env": r"%{TEMPDIR}", "join": "=Secret value="} + ... type=dict + VAR &{x: Secret=int} ${SECRET}=42 + 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. + ${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 + +User keyword: Receive not secret var + [Documentation] FAIL + ... ValueError: Argument 'secret' must have type 'Secret', got integer. + User Keyword: Receive secret ${666} + +Library keyword + User Keyword: Receive secret ${SECRET} Secret value + +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: 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} Secret value, Secret value + +*** Keywords *** +User Keyword: Receive secret + [Arguments] ${secret: secret} ${expected: str}=not set + 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/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/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/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/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_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..ba4960d8c20 100644 --- a/atest/testdata/test_libraries/library_with_1_parameters.robot +++ b/atest/testdata/test_libraries/library_with_1_parameters.robot @@ -1,10 +1,10 @@ *** Settings *** -Library ParameterLibrary myhost +Library ParameterLibrary myhost +Library ParameterLibrary myhost +Library ParameterLibrary different! *** 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 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/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/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/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst index 01279797cf8..bbd961507b6 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 ~~~~~~~~~~~~~ @@ -1412,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. @@ -1420,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 @@ -1451,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/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 diff --git a/setup.py b/setup.py index 946654fa060..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.2" +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/api/types.py b/src/robot/api/types.py new file mode 100644 index 00000000000..63c36f49628 --- /dev/null +++ b/src/robot/api/types.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/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/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() 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/libraries/BuiltIn.py b/src/robot/libraries/BuiltIn.py index 18d09af57d8..866cf4fbb9a 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 | @@ -1734,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 @@ -1743,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 @@ -1767,7 +1765,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] @@ -1784,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 @@ -1794,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`. """ @@ -1810,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 @@ -1820,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`. """ @@ -1842,7 +1838,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 +1856,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. @@ -1890,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 @@ -1930,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 @@ -1966,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 @@ -1978,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 @@ -2051,12 +2047,12 @@ 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 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,14 +2131,8 @@ 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): + 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 @@ -2216,7 +2206,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` | @@ -2287,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 @@ -2370,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. @@ -2378,11 +2368,12 @@ 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): + 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 @@ -2395,7 +2386,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. @@ -2408,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 @@ -2417,7 +2408,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. """ @@ -2427,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,13 +2433,13 @@ 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" @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`. @@ -2468,16 +2459,16 @@ 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`` 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 +2481,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 +2500,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.*``. @@ -2546,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 @@ -2607,24 +2598,24 @@ 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) - 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 @@ -2726,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 @@ -2753,7 +2744,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 | @@ -2786,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 @@ -2800,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 @@ -2814,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 @@ -2834,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 @@ -2848,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 @@ -2922,7 +2913,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 +2980,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 +3056,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 +3076,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) @@ -3128,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 @@ -3143,8 +3134,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) @@ -3154,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 @@ -3166,8 +3157,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 +3180,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,11 +3211,11 @@ 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) - 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, @@ -3271,9 +3262,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 +3411,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 +3428,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 +3483,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,10 +3509,10 @@ 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): + 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 @@ -3558,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 @@ -3583,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 @@ -3689,7 +3679,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 +3762,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 +3888,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 +3936,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 +3962,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 +3993,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 +4018,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 +4046,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 +4058,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}'.") diff --git a/src/robot/libraries/DateTime.py b/src/robot/libraries/DateTime.py index 3a482d9086f..dd884937766 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 Literal, Union from robot.utils import ( elapsed_time_to_string, secs_to_timestr, timestr_to_secs, type_name @@ -325,13 +326,20 @@ "subtract_time_from_time", ] +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="local", - increment=0, - result_format="timestamp", - exclude_millis=False, -): + time_zone: Literal["local", "UTC"] = "local", + increment: TimeInput = 0, + result_format: DateFormat = "timestamp", + exclude_millis: bool = False, +) -> DateOutput: """Returns current local or UTC time with an optional increment. Arguments: @@ -373,11 +381,11 @@ def get_current_date( def convert_date( - date, - result_format="timestamp", - exclude_millis=False, - date_format=None, -): + date: DateInput, + result_format: DateFormat = "timestamp", + exclude_millis: bool = False, + date_format: "str | None" = None, +) -> DateOutput: """Converts between supported `date formats`. Arguments: @@ -398,7 +406,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: TimeFormat = "number", + exclude_millis: bool = False, +) -> TimeOutput: """Converts between supported `time formats`. Arguments: @@ -419,13 +431,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: TimeFormat = "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 +462,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: DateFormat = "timestamp", + exclude_millis: bool = False, + date_format: "str | None" = None, +) -> DateOutput: """Adds time to date and returns the resulting date. Arguments: @@ -479,12 +491,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: DateFormat = "timestamp", + exclude_millis: bool = False, + date_format: "str | None" = None, +) -> DateOutput: """Subtracts time from date and returns the resulting date. Arguments: @@ -507,7 +519,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: TimeFormat = "number", + exclude_millis: bool = False, +) -> TimeOutput: """Adds time to another time and returns the resulting time. Arguments: @@ -527,7 +544,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: TimeFormat = "number", + exclude_millis: bool = False, +) -> TimeOutput: """Subtracts time from another time and returns the resulting time. Arguments: @@ -550,15 +572,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 +599,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 +620,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 +636,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 +648,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 +672,14 @@ def __sub__(self, other): class Time: - def __init__(self, time): - self.seconds = float(self._convert_time_to_seconds(time)) - - def _convert_time_to_seconds(self, time): - if isinstance(time, datetime.timedelta): - return time.total_seconds() - return timestr_to_secs(time, round_to=None) + def __init__(self, time: TimeInput): + self.seconds = 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 +687,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/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``. 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:: 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/running/model.py b/src/robot/running/model.py index b3e7e6cabdf..92abcbee182 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: diff --git a/src/robot/running/namespace.py b/src/robot/running/namespace.py index ffe7a6f1c8e..ee828b3cd45 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,19 @@ 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}'." - ) + self._duplicate_library_import_warning(lib, import_.source, import_.lineno) 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: @@ -169,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) 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): 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, diff --git a/src/robot/utils/__init__.py b/src/robot/utils/__init__.py index 9e619bd12ac..2dc27bbf60d 100644 --- a/src/robot/utils/__init__.py +++ b/src/robot/utils/__init__.py @@ -144,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/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") 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() diff --git a/src/robot/utils/robottime.py b/src/robot/utils/robottime.py index 530a4ae7b46..e12657f28a4 100644 --- a/src/robot/utils/robottime.py +++ b/src/robot/utils/robottime.py @@ -38,7 +38,10 @@ 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 | None" = 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, diff --git a/src/robot/utils/secret.py b/src/robot/utils/secret.py new file mode 100644 index 00000000000..1a59c5bb728 --- /dev/null +++ b/src/robot/utils/secret.py @@ -0,0 +1,40 @@ +# 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=)" + + def __repr__(self): + return str(self) diff --git a/src/robot/variables/scopes.py b/src/robot/variables/scopes.py index bd302bab5be..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,10 +212,12 @@ 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}") + 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..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, split_from_equals +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 @@ -111,6 +111,29 @@ def resolve(self, variables) -> Any: def _replace_variables(self, variables) -> Any: raise NotImplementedError + def _handle_secrets(self, value, replace_scalar): + match = search_variable(value, identifiers="$%") + if match.is_variable(): + 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: + value = replace_scalar(match.match) + if match.identifier == "%": + secret_seen = True + elif isinstance(value, Secret): + value = value.value + secret_seen = True + 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) + return Secret(value) if secret_seen else value + def _convert(self, value, type_): from robot.running import TypeInfo @@ -126,6 +149,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,6 +179,8 @@ 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): + if self._is_secret_type(): + return self._handle_secrets(value[0], variables.replace_scalar) return variables.replace_scalar(value[0]) if separator is None: separator = " " @@ -167,7 +196,15 @@ def _is_single_value(self, value, separator): class ListVariableResolver(VariableResolver): def _replace_variables(self, variables): - 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_}]") @@ -198,13 +235,30 @@ def _replace_variables(self, variables): raise DataError(f"Creating dictionary variable failed: {err}") def _yield_replaced(self, values, replace_scalar): + if not self.type: + secret_key = secret_value = False + elif "=" not in self.type: + secret_key = False + secret_value = self._is_secret_type(self.type) + else: + kt, vt = self.type.split("=", 1) + secret_key = self._is_secret_type(kt) + secret_value = self._is_secret_type(vt) for item in values: if isinstance(item, tuple): - key, values = item - yield replace_scalar(key), replace_scalar(values) + 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 = 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}]") diff --git a/src/robot/version.py b/src/robot/version.py index d5005bfcec7..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.2" +VERSION = "7.4.dev1" def get_version(naked=False):